<!DOCTYPE html>
<html>
<head>
    <meta name="renderer" content="webkit"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <script charset="utf-8" src="http://static.ady01.com/p-core-mobile/js/Jquery.1.8.js"></script>

    <title>数据结构与算法之美</title>
    <style type="text/css">
        html, body {
            margin: 0px;
            padding: 0px;
            width: 100%;
            height: 100%;
            font-size: 14px;
        }

        .con_left {
            float: left;
            width: 350px;
            height: 100%;
            overflow: scroll;
        }

        .con_right {
            margin-left: 350px;
            height: 100%;
            overflow: scroll;
        }

        .con-rigth-in {
            padding: 10px;
        }

        #content_api {
            padding: 10px;
        }

        .head_title {
            text-align: center;
            font-size: 24px;
            margin: 10px;
            font-weight: bold;
        }

        .ol_apis {
            padding: 5px;
            font-family: tahoma, Arial, Microsoft YaHei, \5b8b\4f53;
            list-style: none;
        }

        .ol_apis li {
            background: #eee;
            border-radius: 5px;
            margin-bottom: 5px;
            padding: 5px;
        }

        .ol_apis a {
            display: inline-block;
            text-decoration: none;
        }

        .ol_apis a:hover {
            color: #F90;
        }

        .ol_apis .api_title {
            font-size: 15px;
            font-weight: bold;
            cursor: pointer;
            display: block;
        }

        .ol_api.selected {
            background: #FF9400;
            color: #FFF;
        }

        .fun_comment {
            background: #f3f5f7;
        }

        .float_r {
            float: right;
        }

        .hidden {
            display: none;
        }

        .clear_both {
            clear: both;
        }

        #con_right {
            padding: 10px;
        }

        #article_title {
            font-size: 24px;
            font-weight: bold;
            line-height: 30px;
        }

        #audio_download_url {
            color: #CC0000;
        }

        #videomp3 {
            height: 50px;
            width: 400px;
        }

        #article_content {
            font-size: 14px;
        }
        code{
            font-size: .8rem;
            font-family: Consolas,Liberation Mono,Menlo,monospace,Courier;
            display: block;
            width: 100%;
            box-sizing: border-box;
            /* padding-left: 1rem; */
            /* padding-right: 1rem; */
            overflow-x: auto;
            background: hsla(0,0%,97%,.7);
            border: 1px solid #ddd;
            padding: 1em 1.5em;
        }
    </style>
    <script type="text/javascript">
        var articleList = [
	{
		"article_content":"<p>上一节我们学习了树、二叉树以及二叉树的遍历，今天我们再来学习一种特殊的的二叉树，二叉查找树。二叉查找树最大的特点就是，支持动态数据集合的快速插入、删除、查找操作。</p><p>我们之前说过，散列表也是支持这些操作的，并且散列表的这些操作比二叉查找树更高效，时间复杂度是O(1)。<strong><span class=\"orange\">既然有了这么高效的散列表，使用二叉树的地方是不是都可以替换成散列表呢？有没有哪些地方是散列表做不了，必须要用二叉树来做的呢？</span></strong></p><p>带着这些问题，我们就来学习今天的内容，二叉查找树！</p><h2>二叉查找树（Binary Search Tree）</h2><p>二叉查找树是二叉树中最常用的一种类型，也叫二叉搜索树。顾名思义，二叉查找树是为了实现快速查找而生的。不过，它不仅仅支持快速查找一个数据，还支持快速插入、删除一个数据。它是怎么做到这些的呢？</p><p>这些都依赖于二叉查找树的特殊结构。<strong>二叉查找树要求，在树中的任意一个节点，其左子树中的每个节点的值，都要小于这个节点的值，而右子树节点的值都大于这个节点的值。</strong> 我画了几个二叉查找树的例子，你一看应该就清楚了。</p><p><img src=\"https://static001.geekbang.org/resource/image/f3/ae/f3bb11b6d4a18f95aa19e11f22b99bae.jpg\" alt=\"\"></p><p>前面我们讲到，二叉查找树支持快速查找、插入、删除操作，现在我们就依次来看下，这三个操作是如何实现的。</p><h3>1.二叉查找树的查找操作</h3><!-- [[[read_end]]] --><p>首先，我们看如何在二叉查找树中查找一个节点。我们先取根节点，如果它等于我们要查找的数据，那就返回。如果要查找的数据比根节点的值小，那就在左子树中递归查找；如果要查找的数据比根节点的值大，那就在右子树中递归查找。</p><p><img src=\"https://static001.geekbang.org/resource/image/96/2a/96b3d86ed9b7c4f399e8357ceed0db2a.jpg\" alt=\"\"></p><p>这里我把查找的代码实现了一下，贴在下面了，结合代码，理解起来会更加容易。</p><pre><code>public class BinarySearchTree {\n  private Node tree;\n\n  public Node find(int data) {\n    Node p = tree;\n    while (p != null) {\n      if (data &lt; p.data) p = p.left;\n      else if (data &gt; p.data) p = p.right;\n      else return p;\n    }\n    return null;\n  }\n\n  public static class Node {\n    private int data;\n    private Node left;\n    private Node right;\n\n    public Node(int data) {\n      this.data = data;\n    }\n  }\n}\n</code></pre><h3>2.二叉查找树的插入操作</h3><p>二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上，所以我们只需要从根节点开始，依次比较要插入的数据和节点的大小关系。</p><p>如果要插入的数据比节点的数据大，并且节点的右子树为空，就将新数据直接插到右子节点的位置；如果不为空，就再递归遍历右子树，查找插入位置。同理，如果要插入的数据比节点数值小，并且节点的左子树为空，就将新数据插入到左子节点的位置；如果不为空，就再递归遍历左子树，查找插入位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/da/c5/daa9fb557726ee6183c5b80222cfc5c5.jpg\" alt=\"\"></p><p>同样，插入的代码我也实现了一下，贴在下面，你可以看看。</p><pre><code>public void insert(int data) {\n  if (tree == null) {\n    tree = new Node(data);\n    return;\n  }\n\n  Node p = tree;\n  while (p != null) {\n    if (data &gt; p.data) {\n      if (p.right == null) {\n        p.right = new Node(data);\n        return;\n      }\n      p = p.right;\n    } else { // data &lt; p.data\n      if (p.left == null) {\n        p.left = new Node(data);\n        return;\n      }\n      p = p.left;\n    }\n  }\n}\n</code></pre><h3>3.二叉查找树的删除操作</h3><p>二叉查找树的查找、插入操作都比较简单易懂，但是它的删除操作就比较复杂了 。针对要删除节点的子节点个数的不同，我们需要分三种情况来处理。</p><p>第一种情况是，如果要删除的节点没有子节点，我们只需要直接将父节点中，指向要删除节点的指针置为null。比如图中的删除节点55。</p><p>第二种情况是，如果要删除的节点只有一个子节点（只有左子节点或者右子节点），我们只需要更新父节点中，指向要删除节点的指针，让它指向要删除节点的子节点就可以了。比如图中的删除节点13。</p><p>第三种情况是，如果要删除的节点有两个子节点，这就比较复杂了。我们需要找到这个节点的右子树中的最小节点，把它替换到要删除的节点上。然后再删除掉这个最小节点，因为最小节点肯定没有左子节点（如果有左子结点，那就不是最小节点了），所以，我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点18。</p><p><img src=\"https://static001.geekbang.org/resource/image/29/2c/299c615bc2e00dc32225f4d9e3490e2c.jpg\" alt=\"\"></p><p>老规矩，我还是把删除的代码贴在这里。</p><pre><code>public void delete(int data) {\n  Node p = tree; // p指向要删除的节点，初始化指向根节点\n  Node pp = null; // pp记录的是p的父节点\n  while (p != null &amp;&amp; p.data != data) {\n    pp = p;\n    if (data &gt; p.data) p = p.right;\n    else p = p.left;\n  }\n  if (p == null) return; // 没有找到\n\n  // 要删除的节点有两个子节点\n  if (p.left != null &amp;&amp; p.right != null) { // 查找右子树中最小节点\n    Node minP = p.right;\n    Node minPP = p; // minPP表示minP的父节点\n    while (minP.left != null) {\n      minPP = minP;\n      minP = minP.left;\n    }\n    p.data = minP.data; // 将minP的数据替换到p中\n    p = minP; // 下面就变成了删除minP了\n    pp = minPP;\n  }\n\n  // 删除节点是叶子节点或者仅有一个子节点\n  Node child; // p的子节点\n  if (p.left != null) child = p.left;\n  else if (p.right != null) child = p.right;\n  else child = null;\n\n  if (pp == null) tree = child; // 删除的是根节点\n  else if (pp.left == p) pp.left = child;\n  else pp.right = child;\n}\n</code></pre><p>实际上，关于二叉查找树的删除操作，还有个非常简单、取巧的方法，就是单纯将要删除的节点标记为“已删除”，但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中，比较浪费内存空间，但是删除操作就变得简单了很多。而且，这种处理方法也并没有增加插入、查找操作代码实现的难度。</p><h3>4.二叉查找树的其他操作</h3><p>除了插入、删除、查找操作之外，二叉查找树中还可以支持<strong>快速地查找最大节点和最小节点、前驱节点和后继节点</strong>。这些操作我就不一一展示了。我会将相应的代码放到GitHub上，你可以自己先实现一下，然后再去上面看。</p><p>二叉查找树除了支持上面几个操作之外，还有一个重要的特性，就是<strong>中序遍历二叉查找树，可以输出有序的数据序列，时间复杂度是O(n)，非常高效</strong>。因此，二叉查找树也叫作二叉排序树。</p><h2>支持重复数据的二叉查找树</h2><p>前面讲二叉查找树的时候，我们默认树中节点存储的都是数字。很多时候，在实际的软件开发中，我们在二叉查找树中存储的，是一个包含很多字段的对象。我们利用对象的某个字段作为键值（key）来构建二叉查找树。我们把对象中的其他字段叫作卫星数据。</p><p>前面我们讲的二叉查找树的操作，针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同，这种情况该怎么处理呢？我这里有两种解决方法。</p><p>第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据，因此我们通过链表和支持动态扩容的数组等数据结构，把值相同的数据都存储在同一个节点上。</p><p>第二种方法比较不好理解，不过更加优雅。</p><p>每个节点仍然只存储一个数据。在查找插入位置的过程中，如果碰到一个节点的值，与要插入数据的值相同，我们就将这个要插入的数据放到这个节点的右子树，也就是说，把这个新插入的数据当作大于这个节点的值来处理。</p><p><img src=\"https://static001.geekbang.org/resource/image/3f/5f/3f59a40e3d927f567022918d89590a5f.jpg\" alt=\"\"></p><p>当要查找数据的时候，遇到值相同的节点，我们并不停止查找操作，而是继续在右子树中查找，直到遇到叶子节点，才停止。这样就可以把键值等于要查找值的所有节点都找出来。</p><p><img src=\"https://static001.geekbang.org/resource/image/fb/ff/fb7b320efd59a05469d6d6fcf0c98eff.jpg\" alt=\"\"></p><p>对于删除操作，我们也需要先查找到每个要删除的节点，然后再按前面讲的删除操作的方法，依次删除。</p><p><img src=\"https://static001.geekbang.org/resource/image/25/17/254a4800703d31612c0af63870260517.jpg\" alt=\"\"></p><h2>二叉查找树的时间复杂度分析</h2><p>好了，对于二叉查找树常用操作的实现方式，你应该掌握得差不多了。现在，我们来分析一下，二叉查找树的插入、删除、查找操作的时间复杂度。</p><p>实际上，二叉查找树的形态各式各样。比如这个图中，对于同一组数据，我们构造了三种二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树，根节点的左右子树极度不平衡，已经退化成了链表，所以查找的时间复杂度就变成了O(n)。</p><p><img src=\"https://static001.geekbang.org/resource/image/e3/d9/e3d9b2977d350526d2156f01960383d9.jpg\" alt=\"\"></p><p>我刚刚其实分析了一种最糟糕的情况，我们现在来分析一个最理想的情况，二叉查找树是一棵完全二叉树（或满二叉树）。这个时候，插入、删除、查找的时间复杂度是多少呢？</p><p>从我前面的例子、图，以及还有代码来看，不管操作是插入、删除还是查找，<strong>时间复杂度<strong><strong>其实</strong></strong>都跟树的高度成正比，也就是O(height)</strong>。既然这样，现在问题就转变成另外一个了，也就是，如何求一棵包含n个节点的完全二叉树的高度？</p><p>树的高度就等于最大层数减一，为了方便计算，我们转换成层来表示。从图中可以看出，包含n个节点的完全二叉树中，第一层包含1个节点，第二层包含2个节点，第三层包含4个节点，依次类推，下面一层节点个数是上一层的2倍，第K层包含的节点个数就是2^(K-1)。</p><p>不过，对于完全二叉树来说，最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在1个到2^(L-1)个之间（我们假设最大层数是L）。如果我们把每一层的节点个数加起来就是总的节点个数n。也就是说，如果节点的个数是n，那么n满足这样一个关系：</p><pre><code>n &gt;= 1+2+4+8+...+2^(L-2)+1\nn &lt;= 1+2+4+8+...+2^(L-2)+2^(L-1)\n</code></pre><p>借助等比数列的求和公式，我们可以计算出，L的范围是[log<sub>2</sub>(n+1), log<sub>2</sub>n +1]。完全二叉树的层数小于等于log<sub>2</sub>n +1，也就是说，完全二叉树的高度小于等于log<sub>2</sub>n。</p><p>显然，极度不平衡的二叉查找树，它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据，在任何时候，都能保持任意节点左右子树都比较平衡的二叉查找树，这就是我们下一节课要详细讲的，一种特殊的二叉查找树，平衡二叉查找树。平衡二叉查找树的高度接近logn，所以插入、删除、查找操作的时间复杂度也比较稳定，是O(logn)。</p><h2>解答开篇</h2><p>我们在散列表那节中讲过，散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1)，非常高效。而二叉查找树在比较平衡的情况下，插入、删除、查找操作时间复杂度才是O(logn)，相对散列表，好像并没有什么优势，那我们为什么还要用二叉查找树呢？</p><p>我认为有下面几个原因：</p><p>第一，散列表中的数据是无序存储的，如果要输出有序的数据，需要先进行排序。而对于二叉查找树来说，我们只需要中序遍历，就可以在O(n)的时间复杂度内，输出有序的数据序列。</p><p>第二，散列表扩容耗时很多，而且当遇到散列冲突时，性能不稳定，尽管二叉查找树的性能不稳定，但是在工程中，我们最常用的平衡二叉查找树的性能非常稳定，时间复杂度稳定在O(logn)。</p><p>第三，笼统地来说，尽管散列表的查找等操作的时间复杂度是常量级的，但因为哈希冲突的存在，这个常量不一定比logn小，所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时，也不一定就比平衡二叉查找树的效率高。</p><p>第四，散列表的构造比二叉查找树要复杂，需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题，而且这个问题的解决方案比较成熟、固定。</p><p>最后，为了避免过多的散列冲突，散列表装载因子不能太大，特别是基于开放寻址法解决冲突的散列表，不然会浪费一定的存储空间。</p><p>综合这几点，平衡二叉查找树在某些方面还是优于散列表的，所以，这两者的存在并不冲突。我们在实际的开发过程中，需要结合具体的需求来选择使用哪一个。</p><h2>内容小结</h2><p>今天我们学习了一种特殊的二叉树，二叉查找树。它支持快速地查找、插入、删除操作。</p><p>二叉查找树中，每个节点的值都大于左子树节点的值，小于右子树节点的值。不过，这只是针对没有重复数据的情况。对于存在重复数据的二叉查找树，我介绍了两种构建方法，一种是让每个节点存储多个值相同的数据；另一种是，每个节点中存储一个数据。针对这种情况，我们只需要稍加改造原来的插入、删除、查找操作即可。</p><p>在二叉查找树中，查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复杂度分别是O(n)和O(logn)，分别对应二叉树退化成链表的情况和完全二叉树。</p><p>为了避免时间复杂度的退化，针对二叉查找树，我们又设计了一种更加复杂的树，平衡二叉查找树，时间复杂度可以做到稳定的O(logn)，下一节我们具体来讲。</p><h2>课后思考</h2><p>今天我讲了二叉树高度的理论分析方法，给出了粗略的数量级。如何通过编程，求出一棵给定二叉树的确切高度呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"24 | 二叉树基础（下）：有了如此高效的散列表，为什么还需要二叉树？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/f1/52/f174516a8527bf199af732b14b3cfc52.mp3",
		"column_id":126,
		"id":68334,
		"site_source_url":"https://time.geekbang.org/column/article/68334"
	},
	{
		"article_content":"<p>前面我们讲的都是线性表结构，栈、队列等等。今天我们讲一种非线性表结构，树。树这种数据结构比线性表的数据结构要复杂得多，内容也比较多，所以我会分四节来讲解。</p><p><img src=\"https://static001.geekbang.org/resource/image/6c/c9/6ce8707f43e1a3e7e5368167cca6a4c9.jpg\" alt=\"\"></p><p>我反复强调过，带着问题学习，是最有效的学习方式之一，所以在正式的内容开始之前，我还是给你出一道思考题：<strong><span class=\"orange\">二叉树有哪几种存储方式？什么样的二叉树适合用数组来存储？</span></strong></p><p>带着这些问题，我们就来学习今天的内容，树！</p><h2>树（Tree）</h2><p>我们首先来看，什么是“树”？再完备的定义，都没有图直观。所以我在图中画了几棵“树”。你来看看，这些“树”都有什么特征？</p><p><img src=\"https://static001.geekbang.org/resource/image/b7/29/b7043bf29a253bb36221eaec62b2e129.jpg\" alt=\"\"></p><p>你有没有发现，“树”这种数据结构真的很像我们现实生活中的“树”，这里面每个元素我们叫作“节点”；用来连线相邻节点之间的关系，我们叫作“父子关系”。</p><p>比如下面这幅图，A节点就是B节点的<strong>父节点</strong>，B节点是A节点的<strong>子节点</strong>。B、C、D这三个节点的父节点是同一个节点，所以它们之间互称为<strong>兄弟节点</strong>。我们把没有父节点的节点叫作<strong>根节点</strong>，也就是图中的节点E。我们把没有子节点的节点叫作<strong>叶子节点</strong>或者<strong>叶节点</strong>，比如图中的G、H、I、J、K、L都是叶子节点。</p><p><img src=\"https://static001.geekbang.org/resource/image/22/ae/220043e683ea33b9912425ef759556ae.jpg\" alt=\"\"></p><p>除此之外，关于“树”，还有三个比较相似的概念：<strong>高度</strong>（Height）、<strong>深度</strong>（Depth）、<strong>层</strong>（Level）。它们的定义是这样的：</p><!-- [[[read_end]]] --><p><img src=\"https://static001.geekbang.org/resource/image/40/1e/4094a733986073fedb6b9d03f877d71e.jpg\" alt=\"\"></p><p>这三个概念的定义比较容易混淆，描述起来也比较空洞。我举个例子说明一下，你一看应该就能明白。</p><p><img src=\"https://static001.geekbang.org/resource/image/50/b4/50f89510ad1f7570791dd12f4e9adeb4.jpg\" alt=\"\"></p><p>记这几个概念，我还有一个小窍门，就是类比“高度”“深度”“层”这几个名词在生活中的含义。</p><p>在我们的生活中，“高度”这个概念，其实就是从下往上度量，比如我们要度量第10层楼的高度、第13层楼的高度，起点都是地面。所以，树这种数据结构的高度也是一样，从最底层开始计数，并且计数的起点是0。</p><p>“深度”这个概念在生活中是从上往下度量的，比如水中鱼的深度，是从水平面开始度量的。所以，树这种数据结构的深度也是类似的，从根结点开始度量，并且计数起点也是0。</p><p>“层数”跟深度的计算类似，不过，计数起点是1，也就是说根节点的位于第1层。</p><h2>二叉树（Binary Tree）</h2><p>树结构多种多样，不过我们最常用还是二叉树。</p><p>二叉树，顾名思义，每个节点最多有两个“叉”，也就是两个子节点，分别是<strong>左子节点</strong>和<strong>右子<strong><strong>节</strong></strong>点</strong>。不过，二叉树并不要求每个节点都有两个子节点，有的节点只有左子节点，有的节点只有右子节点。我画的这几个都是二叉树。以此类推，你可以想象一下四叉树、八叉树长什么样子。</p><p><img src=\"https://static001.geekbang.org/resource/image/09/2b/09c2972d56eb0cf67e727deda0e9412b.jpg\" alt=\"\"></p><p>这个图里面，有两个比较特殊的二叉树，分别是编号2和编号3这两个。</p><p>其中，编号2的二叉树中，叶子节点全都在最底层，除了叶子节点之外，每个节点都有左右两个子节点，这种二叉树就叫作<strong>满二叉树</strong>。</p><p>编号3的二叉树中，叶子节点都在最底下两层，最后一层的叶子节点都靠左排列，并且除了最后一层，其他层的节点个数都要达到最大，这种二叉树叫作<strong>完全二叉树</strong>。</p><p>满二叉树很好理解，也很好识别，但是完全二叉树，有的人可能就分不清了。我画了几个完全二叉树和非完全二叉树的例子，你可以对比着看看。</p><p><img src=\"https://static001.geekbang.org/resource/image/18/60/18413c6597c2850b75367393b401ad60.jpg\" alt=\"\"></p><p>你可能会说，满二叉树的特征非常明显，我们把它单独拎出来讲，这个可以理解。但是完全二叉树的特征不怎么明显啊，单从长相上来看，完全二叉树并没有特别特殊的地方啊，更像是“芸芸众树”中的一种。</p><p>那我们为什么还要特意把它拎出来讲呢？为什么偏偏把最后一层的叶子节点靠左排列的叫完全二叉树？如果靠右排列就不能叫完全二叉树了吗？这个定义的由来或者说目的在哪里？</p><p>要理解完全二叉树定义的由来，我们需要先了解，<strong>如何表示（或者存储）一棵二叉树？</strong></p><p>想要存储一棵二叉树，我们有两种方法，一种是基于指针或者引用的二叉链式存储法，一种是基于数组的顺序存储法。</p><p>我们先来看比较简单、直观的<strong>链式存储法</strong>。从图中你应该可以很清楚地看到，每个节点有三个字段，其中一个存储数据，另外两个是指向左右子节点的指针。我们只要拎住根节点，就可以通过左右子节点的指针，把整棵树都串起来。这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。</p><p><img src=\"https://static001.geekbang.org/resource/image/12/8e/12cd11b2432ed7c4dfc9a2053cb70b8e.jpg\" alt=\"\"></p><p>我们再来看，基于数组的<strong>顺序存储法</strong>。我们把根节点存储在下标i = 1的位置，那左子节点存储在下标2 * i = 2的位置，右子节点存储在2 * i + 1 = 3的位置。以此类推，B节点的左子节点存储在2 * i = 2 * 2 = 4的位置，右子节点存储在2 * i + 1 = 2 * 2 + 1 = 5的位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/14/30/14eaa820cb89a17a7303e8847a412330.jpg\" alt=\"\"></p><p>我来总结一下，如果节点X存储在数组中下标为i的位置，下标为2 * i 的位置存储的就是左子节点，下标为2 * i + 1的位置存储的就是右子节点。反过来，下标为i/2的位置存储就是它的父节点。通过这种方式，我们只要知道根节点存储的位置（一般情况下，为了方便计算子节点，根节点会存储在下标为1的位置），这样就可以通过下标计算，把整棵树都串起来。</p><p>不过，我刚刚举的例子是一棵完全二叉树，所以仅仅“浪费”了一个下标为0的存储位置。如果是非完全二叉树，其实会浪费比较多的数组存储空间。你可以看我举的下面这个例子。</p><p><img src=\"https://static001.geekbang.org/resource/image/08/23/08bd43991561ceeb76679fbb77071223.jpg\" alt=\"\"></p><p>所以，如果某棵二叉树是一棵完全二叉树，那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样，要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因，也是为什么完全二叉树要求最后一层的子节点都靠左的原因。</p><p>当我们讲到堆和堆排序的时候，你会发现，堆其实就是一种完全二叉树，最常用的存储方式就是数组。</p><h2>二叉树的遍历</h2><p>前面我讲了二叉树的基本定义和存储方法，现在我们来看二叉树中非常重要的操作，二叉树的遍历。这也是非常常见的面试题。</p><p>如何将所有节点都遍历打印出来呢？经典的方法有三种，<strong>前序遍历</strong>、<strong>中序遍历</strong>和<strong>后序遍历</strong>。其中，前、中、后序，表示的是节点与它的左右子树节点遍历打印的先后顺序。</p><ul>\n<li>\n<p>前序遍历是指，对于树中的任意节点来说，先打印这个节点，然后再打印它的左子树，最后打印它的右子树。</p>\n</li>\n<li>\n<p>中序遍历是指，对于树中的任意节点来说，先打印它的左子树，然后再打印它本身，最后打印它的右子树。</p>\n</li>\n<li>\n<p>后序遍历是指，对于树中的任意节点来说，先打印它的左子树，然后再打印它的右子树，最后打印这个节点本身。</p>\n</li>\n</ul><p><img src=\"https://static001.geekbang.org/resource/image/ab/16/ab103822e75b5b15c615b68560cb2416.jpg\" alt=\"\"></p><p><strong>实际上，二叉树的前、中、后序遍历就是一个递归的过程</strong>。比如，前序遍历，其实就是先打印根节点，然后再递归地打印左子树，最后递归地打印右子树。</p><p>写递归代码的关键，就是看能不能写出递推公式，而写递推公式的关键就是，如果要解决问题A，就假设子问题B、C已经解决，然后再来看如何利用B、C来解决A。所以，我们可以把前、中、后序遍历的递推公式都写出来。</p><pre><code>前序遍历的递推公式：\npreOrder(r) = print r-&gt;preOrder(r-&gt;left)-&gt;preOrder(r-&gt;right)\n\n中序遍历的递推公式：\ninOrder(r) = inOrder(r-&gt;left)-&gt;print r-&gt;inOrder(r-&gt;right)\n\n后序遍历的递推公式：\npostOrder(r) = postOrder(r-&gt;left)-&gt;postOrder(r-&gt;right)-&gt;print r\n</code></pre><p>有了递推公式，代码写起来就简单多了。这三种遍历方式的代码，我都写出来了，你可以看看。</p><pre><code>void preOrder(Node* root) {\n  if (root == null) return;\n  print root // 此处为伪代码，表示打印root节点\n  preOrder(root-&gt;left);\n  preOrder(root-&gt;right);\n}\n\nvoid inOrder(Node* root) {\n  if (root == null) return;\n  inOrder(root-&gt;left);\n  print root // 此处为伪代码，表示打印root节点\n  inOrder(root-&gt;right);\n}\n\nvoid postOrder(Node* root) {\n  if (root == null) return;\n  postOrder(root-&gt;left);\n  postOrder(root-&gt;right);\n  print root // 此处为伪代码，表示打印root节点\n}\n</code></pre><p>二叉树的前、中、后序遍历的递归实现是不是很简单？你知道<strong>二叉树遍历的时间复杂度是多少</strong>吗？我们一起来看看。</p><p>从我前面画的前、中、后序遍历的顺序图，可以看出来，每个节点最多会被访问两次，所以遍历操作的时间复杂度，跟节点的个数n成正比，也就是说二叉树遍历的时间复杂度是O(n)。</p><h2>解答开篇&amp;内容小结</h2><p>今天，我讲了一种非线性表数据结构，树。关于树，有几个比较常用的概念你需要掌握，那就是：根节点、叶子节点、父节点、子节点、兄弟节点，还有节点的高度、深度、层数，以及树的高度。</p><p>我们平时最常用的树就是二叉树。二叉树的每个节点最多有两个子节点，分别是左子节点和右子节点。二叉树中，有两种比较特殊的树，分别是满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况。</p><p>二叉树既可以用链式存储，也可以用数组顺序存储。数组顺序存储的方式比较适合完全二叉树，其他类型的二叉树用数组存储会比较浪费存储空间。除此之外，二叉树里非常重要的操作就是前、中、后序遍历操作，遍历的时间复杂度是O(n)，你需要理解并能用递归代码来实现。</p><h2>课后思考</h2><ol>\n<li>\n<p>给定一组数据，比如1，3，5，6，9，10。你来算算，可以构建出多少种不同的二叉树？</p>\n</li>\n<li>\n<p>我们讲了三种二叉树的遍历方式，前、中、后序。实际上，还有另外一种遍历方式，也就是按层遍历，你知道如何实现吗？</p>\n</li>\n</ol><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"23 | 二叉树基础（上）：什么样的二叉树适合用数组来存储？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/0d/25/0db7d3110801d629872b86f380835b25.mp3",
		"column_id":126,
		"id":67856,
		"site_source_url":"https://time.geekbang.org/column/article/67856"
	},
	{
		"article_content":"<p>上一节，我讲了哈希算法的四个应用，它们分别是：安全加密、数据校验、唯一标识、散列函数。今天，我们再来看剩余三种应用：<strong>负载均衡、数据分片、分布式存储</strong>。</p><p>你可能已经发现，这三个应用都跟分布式系统有关。没错，今天我就带你看下，<strong><span class=\"orange\">哈希算法是如何解决这些分布式问题的</span></strong>。</p><h2>应用五：负载均衡</h2><p>我们知道，负载均衡算法有很多，比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞（session sticky）的负载均衡算法呢？也就是说，我们需要在同一个客户端上，在一次会话中的所有请求都路由到同一个服务器上。</p><p>最直接的方法就是，维护一张映射关系表，这张表的内容是客户端IP地址或者会话ID与服务器编号的映射关系。客户端发出的每次请求，都要先在映射表中查找应该路由到的服务器编号，然后再请求编号对应的服务器。这种方法简单直观，但也有几个弊端：</p><ul>\n<li>\n<p>如果客户端很多，映射表可能会很大，比较浪费内存空间；</p>\n</li>\n<li>\n<p>客户端下线、上线，服务器扩容、缩容都会导致映射失效，这样维护映射表的成本就会很大；</p>\n</li>\n</ul><p>如果借助哈希算法，这些问题都可以非常完美地解决。<strong>我们可以通过哈希算法，对客户端IP地址或者会话ID计算哈希值，将取得的哈希值与服务器列表的大小进行取模运算，最终得到的值就是应该被路由到的服务器编号。</strong> 这样，我们就可以把同一个IP过来的所有请求，都路由到同一个后端服务器上。</p><!-- [[[read_end]]] --><h2>应用六：数据分片</h2><p>哈希算法还可以用于数据的分片。我这里有两个例子。</p><h3>1.如何统计“搜索关键词”出现的次数？</h3><p>假如我们有1T的日志文件，这里面记录了用户的搜索关键词，我们想要快速统计出每个关键词被搜索的次数，该怎么做呢？</p><p>我们来分析一下。这个问题有两个难点，第一个是搜索日志很大，没办法放到一台机器的内存中。第二个难点是，如果只用一台机器来处理这么巨大的数据，处理时间会很长。</p><p>针对这两个难点，<strong>我们可以先对数据进行分片，然后采用多台机器处理的方法，来提高处理速度</strong>。具体的思路是这样的：为了提高处理的速度，我们用n台机器并行处理。我们从搜索记录的日志文件中，依次读出每个搜索关键词，并且通过哈希函数计算哈希值，然后再跟n取模，最终得到的值，就是应该被分配到的机器编号。</p><p>这样，哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说，同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数，最后合并起来就是最终的结果。</p><p>实际上，这里的处理过程也是MapReduce的基本设计思想。</p><h3>2.如何快速判断图片是否在图库中？</h3><p>如何快速判断图片是否在图库中？上一节我们讲过这个例子，不知道你还记得吗？当时我介绍了一种方法，即给每个图片取唯一标识（或者信息摘要），然后构建散列表。</p><p>假设现在我们的图库中有1亿张图片，很显然，在单台机器上构建散列表是行不通的。因为单台机器的内存有限，而1亿张图片构建散列表显然远远超过了单台机器的内存上限。</p><p>我们同样可以对数据进行分片，然后采用多机处理。我们准备n台机器，让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片，计算唯一标识，然后与机器个数n求余取模，得到的值就对应要分配的机器编号，然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。</p><p>当我们要判断一个图片是否在图库中的时候，我们通过同样的哈希算法，计算这个图片的唯一标识，然后与机器个数n求余取模。假设得到的值是k，那就去编号k的机器构建的散列表中查找。</p><p>现在，我们来估算一下，给这1亿张图片构建散列表大约需要多少台机器。</p><p>散列表中每个数据单元包含两个信息，哈希值和图片文件的路径。假设我们通过MD5来计算哈希值，那长度就是128比特，也就是16字节。文件路径长度的上限是256字节，我们可以假设平均长度是128字节。如果我们用链表法来解决冲突，那还需要存储指针，指针只占用8字节。所以，散列表中每个数据单元就占用152字节（这里只是估算，并不准确）。</p><p>假设一台机器的内存大小为2GB，散列表的装载因子为0.75，那一台机器可以给大约1000万（2GB*0.75/152）张图片构建散列表。所以，如果要对1亿张图片构建索引，需要大约十几台机器。在工程中，这种估算还是很重要的，能让我们事先对需要投入的资源、资金有个大概的了解，能更好地评估解决方案的可行性。</p><p>实际上，针对这种海量数据的处理问题，我们都可以采用多机分布式处理。借助这种分片的思路，可以突破单机内存、CPU等资源的限制。</p><h2>应用七：分布式存储</h2><p>现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力，一般都采用分布式的方式来存储数据，比如分布式缓存。我们有海量的数据需要缓存，所以一个缓存机器肯定是不够的。于是，我们就需要将数据分布在多台机器上。</p><p>该如何决定将哪个数据放到哪个机器上呢？我们可以借用前面数据分片的思想，即通过哈希算法对数据取哈希值，然后对机器个数取模，这个最终值就是应该存储的缓存机器编号。</p><p>但是，如果数据增多，原来的10个机器已经无法承受了，我们就需要扩容了，比如扩到11个机器，这时候麻烦就来了。因为，这里并不是简单地加个机器就可以了。</p><p>原来的数据是通过与10来取模的。比如13这个数据，存储在编号为3这台机器上。但是新加了一台机器中，我们对数据按照11取模，原来13这个数据就被分配到2号这台机器上了。</p><p><img src=\"https://static001.geekbang.org/resource/image/13/7c/138b060ee522cd2eae83c0c31a16bc7c.jpg\" alt=\"\"></p><p>因此，所有的数据都要重新计算哈希值，然后重新搬移到正确的机器上。这样就相当于，缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存，直接去请求数据库。这样就可能发生<a href=\"https://zh.wikipedia.org/wiki/%E9%9B%AA%E5%B4%A9%E6%95%88%E5%BA%94\">雪崩效应</a>，压垮数据库。</p><p>所以，我们需要一种方法，使得在新加入一个机器后，并不需要做大量的数据搬移。这时候，一<strong>致性哈希算法</strong>就要登场了。</p><p>假设我们有k个机器，数据的哈希值的范围是[0, MAX]。我们将整个范围划分成m个小区间（m远大于k），每个机器负责m/k个小区间。当有新机器加入的时候，我们就将某几个小区间的数据，从原来的机器中搬移到新的机器中。这样，既不用全部重新哈希、搬移数据，也保持了各个机器上数据数量的均衡。</p><p>一致性哈希算法的基本思想就是这么简单。除此之外，它还会借助一个虚拟的环和虚拟结点，更加优美地实现出来。这里我就不展开讲了，如果感兴趣，你可以看下这个<a href=\"https://en.wikipedia.org/wiki/Consistent_hashing\">介绍</a>。</p><p>除了我们上面讲到的分布式缓存，实际上，一致性哈希算法的应用非常广泛，在很多分布式存储系统中，都可以见到一致性哈希算法的影子。</p><h2>解答开篇&amp;内容小结</h2><p>这两节的内容理论不多，比较贴近具体的开发。今天我讲了三种哈希算法在分布式系统中的应用，它们分别是：负载均衡、数据分片、分布式存储。</p><p>在负载均衡应用中，利用哈希算法替代映射表，可以实现一个会话粘滞的负载均衡策略。在数据分片应用中，通过哈希算法对处理的海量数据进行分片，多机分布式处理，可以突破单机资源的限制。在分布式存储应用中，利用一致性哈希算法，可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。</p><h2>课后思考</h2><p>这两节我总共讲了七个哈希算法的应用。实际上，我讲的也只是冰山一角，哈希算法还有很多其他的应用，比如网络协议中的CRC校验、Git commit id等等。除了这些，你还能想到其他用到哈希算法的地方吗？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"22 | 哈希算法（下）：哈希算法在分布式系统中有哪些应用？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/0f/9f/0f619c380afadef360d0774bbe56869f.mp3",
		"column_id":126,
		"id":67388,
		"site_source_url":"https://time.geekbang.org/column/article/67388"
	},
	{
		"article_content":"<p>还记得2011年CSDN的“脱库”事件吗？当时，CSDN网站被黑客攻击，超过600万用户的注册邮箱和密码明文被泄露，很多网友对CSDN明文保存用户密码行为产生了不满。如果你是CSDN的一名工程师，<strong><span class=\"orange\">你会如何存储用户密码这么重要的数据吗？仅仅MD5加密一下存储就够了吗？</span></strong> 要想搞清楚这个问题，就要先弄明白哈希算法。</p><p>哈希算法历史悠久，业界著名的哈希算法也有很多，比如MD5、SHA等。在我们平时的开发中，基本上都是拿现成的直接用。所以，我今天不会重点剖析哈希算法的原理，也不会教你如何设计一个哈希算法，而是从实战的角度告诉你，<strong>在实际的开发中，我们该如何用哈希算法解决问题</strong>。</p><h2>什么是哈希算法？</h2><p>我们前面几节讲到“散列表”“散列函数”，这里又讲到“哈希算法”，你是不是有点一头雾水？实际上，不管是“散列”还是“哈希”，这都是中文翻译的差别，英文其实就是“<strong>Hash</strong>”。所以，我们常听到有人把“散列表”叫作“哈希表”“Hash表”，把“哈希算法”叫作“Hash算法”或者“散列算法”。那到底什么是哈希算法呢？</p><p>哈希算法的定义和原理非常简单，基本上一句话就可以概括了。将任意长度的二进制值串映射为固定长度的二进制值串，这个映射的规则就是<strong>哈希算法</strong>，而通过原始数据映射之后得到的二进制值串就是<strong>哈希值</strong>。但是，要想设计一个优秀的哈希算法并不容易，根据我的经验，我总结了需要满足的几点要求：</p><!-- [[[read_end]]] --><ul>\n<li>\n<p>从哈希值不能反向推导出原始数据（所以哈希算法也叫单向哈希算法）；</p>\n</li>\n<li>\n<p>对输入数据非常敏感，哪怕原始数据只修改了一个Bit，最后得到的哈希值也大不相同；</p>\n</li>\n<li>\n<p>散列冲突的概率要很小，对于不同的原始数据，哈希值相同的概率非常小；</p>\n</li>\n<li>\n<p>哈希算法的执行效率要尽量高效，针对较长的文本，也能快速地计算出哈希值。</p>\n</li>\n</ul><p>这些定义和要求都比较理论，可能还是不好理解，我拿MD5这种哈希算法来具体说明一下。</p><p>我们分别对“今天我来讲哈希算法”和“jiajia”这两个文本，计算MD5哈希值，得到两串看起来毫无规律的字符串（MD5的哈希值是128位的Bit长度，为了方便表示，我把它们转化成了16进制编码）。可以看出来，无论要哈希的文本有多长、多短，通过MD5哈希之后，得到的哈希值的长度都是相同的，而且得到的哈希值看起来像一堆随机数，完全没有规律。</p><pre><code>MD5(&quot;今天我来讲哈希算法&quot;) = bb4767201ad42c74e650c1b6c03d78fa\nMD5(&quot;jiajia&quot;) = cd611a31ea969b908932d44d126d195b\n</code></pre><p>我们再来看两个非常相似的文本，“我今天讲哈希算法！”和“我今天讲哈希算法”。这两个文本只有一个感叹号的区别。如果用MD5哈希算法分别计算它们的哈希值，你会发现，尽管只有一字之差，得到的哈希值也是完全不同的。</p><pre><code>MD5(&quot;我今天讲哈希算法！&quot;) = 425f0d5a917188d2c3c3dc85b5e4f2cb\nMD5(&quot;我今天讲哈希算法&quot;) = a1fb91ac128e6aa37fe42c663971ac3d\n</code></pre><p>我在前面也说了，通过哈希算法得到的哈希值，很难反向推导出原始数据。比如上面的例子中，我们就很难通过哈希值“a1fb91ac128e6aa37fe42c663971ac3d”反推出对应的文本“我今天讲哈希算法”。</p><p>哈希算法要处理的文本可能是各种各样的。比如，对于非常长的文本，如果哈希算法的计算时间很长，那就只能停留在理论研究的层面，很难应用到实际的软件开发中。比如，我们把今天这篇包含4000多个汉字的文章，用MD5计算哈希值，用不了1ms的时间。</p><p>哈希算法的应用非常非常多，我选了最常见的七个，分别是安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。这节我们先来看前四个应用。</p><h2>应用一：安全加密</h2><p>说到哈希算法的应用，最先想到的应该就是安全加密。最常用于加密的哈希算法是<strong>MD5</strong>（MD5 Message-Digest Algorithm，MD5消息摘要算法）和<strong>SHA</strong>（Secure Hash Algorithm，安全散列算法）。</p><p>除了这两个之外，当然还有很多其他加密算法，比如<strong>DES</strong>（Data Encryption Standard，数据加密标准）、<strong>AES</strong>（Advanced Encryption Standard，高级加密标准）。</p><p>前面我讲到的哈希算法四点要求，对用于加密的哈希算法来说，有两点格外重要。第一点是很难根据哈希值反向推导出原始数据，第二点是散列冲突的概率要很小。</p><p>第一点很好理解，加密的目的就是防止原始数据泄露，所以很难通过哈希值反向推导原始数据，这是一个最基本的要求。所以我着重讲一下第二点。实际上，不管是什么哈希算法，我们只能尽量减少碰撞冲突的概率，理论上是没办法做到完全不冲突的。为什么这么说呢？</p><p>这里就基于组合数学中一个非常基础的理论，鸽巢原理（也叫抽屉原理）。这个原理本身很简单，它是说，如果有10个鸽巢，有11只鸽子，那肯定有1个鸽巢中的鸽子数量多于1个，换句话说就是，肯定有2只鸽子在1个鸽巢内。</p><p>有了鸽巢原理的铺垫之后，我们再来看，<strong>为什么哈希算法无法做到零冲突？</strong></p><p>我们知道，哈希算法产生的哈希值的长度是固定且有限的。比如前面举的MD5的例子，哈希值是固定的128位二进制串，能表示的数据是有限的，最多能表示2^128个数据，而我们要哈希的数据是无穷的。基于鸽巢原理，如果我们对2^128+1个数据求哈希值，就必然会存在哈希值相同的情况。这里你应该能想到，一般情况下，哈希值越长的哈希算法，散列冲突的概率越低。</p><pre><code>2^128=340282366920938463463374607431768211456\n</code></pre><p>为了让你能有个更加直观的感受，我找了两段字符串放在这里。这两段字符串经过MD5哈希算法加密之后，产生的哈希值是相同的。</p><p><img src=\"https://static001.geekbang.org/resource/image/65/d8/65ee084ee47ae9ef6f53f618c65d00d8.jpg\" alt=\"\"></p><p><img src=\"https://static001.geekbang.org/resource/image/71/f1/715de12e09843a1c4a5f99ffd00c9ef1.jpg\" alt=\"\"></p><p>不过，即便哈希算法存在散列冲突的情况，但是因为哈希值的范围很大，冲突的概率极低，所以相对来说还是很难破解的。像MD5，有2^128个不同的哈希值，这个数据已经是一个天文数字了，所以散列冲突的概率要小于1/2^128。</p><p>如果我们拿到一个MD5哈希值，希望通过毫无规律的穷举的方法，找到跟这个MD5值相同的另一个数据，那耗费的时间应该是个天文数字。所以，即便哈希算法存在冲突，但是在有限的时间和资源下，哈希算法还是被很难破解的。</p><p>除此之外，没有绝对安全的加密。越复杂、越难破解的加密算法，需要的计算时间也越长。比如SHA-256比SHA-1要更复杂、更安全，相应的计算时间就会比较长。密码学界也一直致力于找到一种快速并且很难被破解的哈希算法。我们在实际的开发过程中，也需要权衡破解难度和计算时间，来决定究竟使用哪种加密算法。</p><h2>应用二：唯一标识</h2><p>我先来举一个例子。如果要在海量的图库中，搜索一张图是否存在，我们不能单纯地用图片的元信息（比如图片名称）来比对，因为有可能存在名称相同但图片内容不同，或者名称不同图片内容相同的情况。那我们该如何搜索呢？</p><p>我们知道，任何文件在计算中都可以表示成二进制码串，所以，比较笨的办法就是，拿要查找的图片的二进制码串与图库中所有图片的二进制码串一一比对。如果相同，则说明图片在图库中存在。但是，每个图片小则几十KB、大则几MB，转化成二进制是一个非常长的串，比对起来非常耗时。有没有比较快的方法呢？</p><p>我们可以给每一个图片取一个唯一标识，或者说信息摘要。比如，我们可以从图片的二进制码串开头取100个字节，从中间取100个字节，从最后再取100个字节，然后将这300个字节放到一块，通过哈希算法（比如MD5），得到一个哈希字符串，用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中，这样就可以减少很多工作量。</p><p>如果还想继续提高效率，我们可以把每个图片的唯一标识，和相应的图片文件在图库中的路径信息，都存储在散列表中。当要查看某个图片是不是在图库中的时候，我们先通过哈希算法对这个图片取唯一标识，然后在散列表中查找是否存在这个唯一标识。</p><p>如果不存在，那就说明这个图片不在图库中；如果存在，我们再通过散列表中存储的文件路径，获取到这个已经存在的图片，跟现在要插入的图片做全量的比对，看是否完全一样。如果一样，就说明已经存在；如果不一样，说明两张图片尽管唯一标识相同，但是并不是相同的图片。</p><h2>应用三：数据校验</h2><p>电驴这样的BT下载软件你肯定用过吧？我们知道，BT下载的原理是基于P2P协议的。我们从多个机器上并行下载一个2GB的电影，这个电影文件可能会被分割成很多文件块（比如可以分成100块，每块大约20MB）。等所有的文件块都下载完成之后，再组装成一个完整的电影文件就行了。</p><p>我们知道，网络传输是不安全的，下载的文件块有可能是被宿主机器恶意修改过的，又或者下载过程中出现了错误，所以下载的文件块可能不是完整的。如果我们没有能力检测这种恶意修改或者文件下载出错，就会导致最终合并后的电影无法观看，甚至导致电脑中毒。现在的问题是，如何来校验文件块的安全、正确、完整呢？</p><p>具体的BT协议很复杂，校验方法也有很多，我来说其中的一种思路。</p><p>我们通过哈希算法，对100个文件块分别取哈希值，并且保存在种子文件中。我们在前面讲过，哈希算法有一个特点，对数据很敏感。只要文件块的内容有一丁点儿的改变，最后计算出的哈希值就会完全不同。所以，当文件块下载完成之后，我们可以通过相同的哈希算法，对下载好的文件块逐一求哈希值，然后跟种子文件中保存的哈希值比对。如果不同，说明这个文件块不完整或者被篡改了，需要再重新从其他宿主机器上下载这个文件块。</p><h2>应用四：散列函数</h2><p>前面讲了很多哈希算法的应用，实际上，散列函数也是哈希算法的一种应用。</p><p>我们前两节讲到，散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过，相对哈希算法的其他应用，散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突，只要不是过于严重，我们都可以通过开放寻址法或者链表法解决。</p><p>不仅如此，散列函数对于散列算法计算得到的值，是否能反向解密也并不关心。散列函数中用到的散列算法，更加关注散列后的值是否能平均分布，也就是，一组数据是否能均匀地散列在各个槽中。除此之外，散列函数执行的快慢，也会影响散列表的性能，所以，散列函数用的散列算法一般都比较简单，比较追求效率。</p><h2>解答开篇</h2><p>好了，有了前面的基础，现在你有没有发现开篇的问题其实很好解决？</p><p>我们可以通过哈希算法，对用户密码进行加密之后再存储，不过最好选择相对安全的加密算法，比如SHA等（因为MD5已经号称被破解了）。不过仅仅这样加密之后存储就万事大吉了吗？</p><p>字典攻击你听说过吗？如果用户信息被“脱库”，黑客虽然拿到是加密之后的密文，但可以通过“猜”的方式来破解密码，这是因为，有些用户的密码太简单。比如很多人习惯用00000、123456这样的简单数字组合做密码，很容易就被猜中。</p><p>那我们就需要维护一个常用密码的字典表，把字典中的每个密码用哈希算法计算哈希值，然后拿哈希值跟脱库后的密文比对。如果相同，基本上就可以认为，这个加密之后的密码对应的明文就是字典中的这个密码。（注意，这里说是的是“基本上可以认为”，因为根据我们前面的学习，哈希算法存在散列冲突，也有可能出现，尽管密文一样，但是明文并不一样的情况。）</p><p>针对字典攻击，我们可以引入一个盐（salt），跟用户的密码组合在一起，增加密码的复杂度。我们拿组合之后的字符串来做哈希算法加密，将它存储到数据库中，进一步增加破解的难度。不过我这里想多说一句，我认为安全和攻击是一种博弈关系，不存在绝对的安全。所有的安全措施，只是增加攻击的成本而已。</p><h2>内容小结</h2><p>今天的内容比较偏实战，我讲到了哈希算法的四个应用场景。我带你来回顾一下。</p><p>第一个应用是唯一标识，哈希算法可以对大数据做信息摘要，通过一个较短的二进制编码来表示很大的数据。</p><p>第二个应用是用于校验数据的完整性和正确性。</p><p>第三个应用是安全加密，我们讲到任何哈希算法都会出现散列冲突，但是这个冲突概率非常小。越是复杂哈希算法越难破解，但同样计算时间也就越长。所以，选择哈希算法的时候，要权衡安全性和计算时间来决定用哪种哈希算法。</p><p>第四个应用是散列函数，这个我们前面讲散列表的时候已经详细地讲过，它对哈希算法的要求非常特别，更加看重的是散列的平均性和哈希算法的执行效率。</p><h2>课后思考</h2><p>现在，区块链是一个很火的领域，它被很多人神秘化，不过其底层的实现原理并不复杂。其中，哈希算法就是它的一个非常重要的理论基础。你能讲一讲区块链使用的是哪种哈希算法吗？是为了解决什么问题而使用的呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"21 | 哈希算法（上）：如何防止数据库中的用户信息被脱库？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/af/c2/afadf162f3ff15c36d6f9d5b776b94c2.mp3",
		"column_id":126,
		"id":65312,
		"site_source_url":"https://time.geekbang.org/column/article/65312"
	},
	{
		"article_content":"<p>我们已经学习了20节内容，你有没有发现，有两种数据结构，散列表和链表，经常会被放在一起使用。你还记得，前面的章节中都有哪些地方讲到散列表和链表的组合使用吗？我带你一起回忆一下。</p><p>在链表那一节，我讲到如何用链表来实现LRU缓存淘汰算法，但是链表实现的LRU缓存淘汰算法的时间复杂度是O(n)，当时我也提到了，通过散列表可以将这个时间复杂度降低到O(1)。</p><p>在跳表那一节，我提到Redis的有序集合是使用跳表来实现的，跳表可以看作一种改进版的链表。当时我们也提到，Redis有序集合不仅使用了跳表，还用到了散列表。</p><p>除此之外，如果你熟悉Java编程语言，你会发现LinkedHashMap这样一个常用的容器，也用到了散列表和链表两种数据结构。</p><p>今天，我们就来看看，在这几个问题中，散列表和链表都是如何组合起来使用的，以及为什么散列表和链表会经常放到一块使用。</p><h2>LRU缓存淘汰算法</h2><p>在链表那一节中，我提到，借助散列表，我们可以把LRU缓存淘汰算法的时间复杂度降低为O(1)。现在，我们就来看看它是如何做到的。</p><p>首先，我们来回顾一下当时我们是如何通过链表实现LRU缓存淘汰算法的。</p><p>我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限，当缓存空间不够，需要淘汰一个数据的时候，我们就直接将链表头部的结点删除。</p><!-- [[[read_end]]] --><p>当要缓存某个数据的时候，先在链表中查找这个数据。如果没有找到，则直接将数据放到链表的尾部；如果找到了，我们就把它移动到链表的尾部。因为查找数据需要遍历链表，所以单纯用链表实现的LRU缓存淘汰算法的时间复杂很高，是O(n)。</p><p>实际上，我总结一下，一个缓存（cache）系统主要包含下面这几个操作：</p><ul>\n<li>\n<p>往缓存中添加一个数据；</p>\n</li>\n<li>\n<p>从缓存中删除一个数据；</p>\n</li>\n<li>\n<p>在缓存中查找一个数据。</p>\n</li>\n</ul><p>这三个操作都要涉及“查找”操作，如果单纯地采用链表的话，时间复杂度只能是O(n)。如果我们将散列表和链表两种数据结构组合使用，可以将这三个操作的时间复杂度都降低到O(1)。具体的结构就是下面这个样子：</p><p><img src=\"https://static001.geekbang.org/resource/image/ea/6e/eaefd5f4028cc7d4cfbb56b24ce8ae6e.jpg\" alt=\"\"></p><p>我们使用双向链表存储数据，链表中的每个结点处理存储数据（data）、前驱指针（prev）、后继指针（next）之外，还新增了一个特殊的字段hnext。这个hnext有什么作用呢？</p><p>因为我们的散列表是通过链表法解决散列冲突的，所以每个结点会在两条链中。一个链是刚刚我们提到的<strong>双向链表</strong>，另一个链是散列表中的<strong>拉链</strong>。<strong>前驱和后继指针是为了将结点串在双向链表中，hnext指针是为了将结点串在散列表的拉链中</strong>。</p><p>了解了这个散列表和双向链表的组合存储结构之后，我们再来看，前面讲到的缓存的三个操作，是如何做到时间复杂度是O(1)的？</p><p>首先，我们来看<strong>如何查找一个数据</strong>。我们前面讲过，散列表中查找数据的时间复杂度接近O(1)，所以通过散列表，我们可以很快地在缓存中找到一个数据。当找到数据之后，我们还需要将它移动到双向链表的尾部。</p><p>其次，我们来看<strong>如何删除一个数据</strong>。我们需要找到数据所在的结点，然后将结点删除。借助散列表，我们可以在O(1)时间复杂度里找到要删除的结点。因为我们的链表是双向链表，双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点，所以在双向链表中，删除结点只需要O(1)的时间复杂度。</p><p>最后，我们来看<strong>如何添加一个数据</strong>。添加数据到缓存稍微有点麻烦，我们需要先看这个数据是否已经在缓存中。如果已经在其中，需要将其移动到双向链表的尾部；如果不在其中，还要看缓存有没有满。如果满了，则将双向链表头部的结点删除，然后再将数据放到链表的尾部；如果没有满，就直接将数据放到链表的尾部。</p><p>这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作，比如删除头结点、链表尾部插入数据等，都可以在O(1)的时间复杂度内完成。所以，这三个操作的时间复杂度都是O(1)。至此，我们就通过散列表和双向链表的组合使用，实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。</p><h2>Redis有序集合</h2><p>在跳表那一节，讲到有序集合的操作时，我稍微做了些简化。实际上，在有序集合中，每个成员对象有两个重要的属性，<strong>key</strong>（键值）和<strong>score</strong>（分值）。我们不仅会通过score来查找数据，还会通过key来查找数据。</p><p>举个例子，比如用户积分排行榜有这样一个功能：我们可以通过用户的ID来查找积分信息，也可以通过积分区间来查找用户ID或者姓名信息。这里包含ID、姓名和积分的用户信息，就是成员对象，用户ID就是key，积分就是score。</p><p>所以，如果我们细化一下Redis有序集合的操作，那就是下面这样：</p><ul>\n<li>\n<p>添加一个成员对象；</p>\n</li>\n<li>\n<p>按照键值来删除一个成员对象；</p>\n</li>\n<li>\n<p>按照键值来查找一个成员对象；</p>\n</li>\n<li>\n<p>按照分值区间查找数据，比如查找积分在[100, 356]之间的成员对象；</p>\n</li>\n<li>\n<p>按照分值从小到大排序成员变量；</p>\n</li>\n</ul><p>如果我们仅仅按照分值将成员对象组织成跳表的结构，那按照键值来删除、查询成员对象就会很慢，解决方法与LRU缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表，这样按照key来删除、查找一个成员对象的时间复杂度就变成了O(1)。同时，借助跳表结构，其他操作也非常高效。</p><p>实际上，Redis有序集合的操作还有另外一类，也就是查找成员对象的排名（Rank）或者根据排名区间查找成员对象。这个功能单纯用刚刚讲的这种组合结构就无法高效实现了。这块内容我后面的章节再讲。</p><h2>Java LinkedHashMap</h2><p>前面我们讲了两个散列表和链表结合的例子，现在我们再来看另外一个，Java中的LinkedHashMap这种容器。</p><p>如果你熟悉Java，那你几乎天天会用到这个容器。我们之前讲过，HashMap底层是通过散列表这种数据结构实现的。而LinkedHashMap前面比HashMap多了一个“Linked”，这里的“Linked”是不是说，LinkedHashMap是一个通过链表法解决散列冲突的散列表呢？</p><p>实际上，LinkedHashMap并没有这么简单，其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。关于这一点，在我是初学者的时候，也误解了很久。</p><p>我们先来看一段代码。你觉得这段代码会以什么样的顺序打印3，1，5，2这几个key呢？原因又是什么呢？</p><pre><code>HashMap&lt;Integer, Integer&gt; m = new LinkedHashMap&lt;&gt;();\nm.put(3, 11);\nm.put(1, 12);\nm.put(5, 23);\nm.put(2, 22);\n\nfor (Map.Entry e : m.entrySet()) {\n  System.out.println(e.getKey());\n}\n</code></pre><p>我先告诉你答案，上面的代码会按照数据插入的顺序依次来打印，也就是说，打印的顺序就是3，1，5，2。你有没有觉得奇怪？散列表中数据是经过散列函数打乱之后无规律存储的，这里是如何实现按照数据的插入顺序来遍历打印的呢？</p><p>你可能已经猜到了，LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上，它不仅支持按照插入顺序遍历数据，还支持按照访问顺序来遍历数据。你可以看下面这段代码：</p><pre><code>// 10是初始大小，0.75是装载因子，true是表示按照访问时间排序\nHashMap&lt;Integer, Integer&gt; m = new LinkedHashMap&lt;&gt;(10, 0.75f, true);\nm.put(3, 11);\nm.put(1, 12);\nm.put(5, 23);\nm.put(2, 22);\n\nm.put(3, 26);\nm.get(5);\n\nfor (Map.Entry e : m.entrySet()) {\n  System.out.println(e.getKey());\n}\n</code></pre><p>这段代码打印的结果是1，2，3，5。我来具体分析一下，为什么这段代码会按照这样顺序来打印。</p><p>每次调用put()函数，往LinkedHashMap中添加数据的时候，都会将数据添加到链表的尾部，所以，在前四个操作完成之后，链表中的数据是下面这样：</p><p><img src=\"https://static001.geekbang.org/resource/image/17/98/17ac41d9dac454e454dcb289100bf198.jpg\" alt=\"\"></p><p>在第8行代码中，再次将键值为3的数据放入到LinkedHashMap的时候，会先查找这个键值是否已经有了，然后，再将已经存在的(3,11)删除，并且将新的(3,26)放到链表的尾部。所以，这个时候链表中的数据就是下面这样：</p><p><img src=\"https://static001.geekbang.org/resource/image/fe/8c/fe313ed327bcf234c73ba738d975b18c.jpg\" alt=\"\"></p><p>当第9行代码访问到key为5的数据的时候，我们将被访问到的数据移动到链表的尾部。所以，第9行代码之后，链表中的数据是下面这样：</p><p><img src=\"https://static001.geekbang.org/resource/image/b5/11/b5e07bb34d532d46d127f4fcc4b78f11.jpg\" alt=\"\"></p><p>所以，最后打印出来的数据是1，2，3，5。从上面的分析，你有没有发现，按照访问时间排序的LinkedHashMap本身就是一个支持LRU缓存淘汰策略的缓存系统？实际上，它们两个的实现原理也是一模一样的。我也就不再啰嗦了。</p><p>我现在来总结一下，实际上，<strong>LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表，并非指用链表法解决散列冲突</strong>。</p><h2>解答开篇&amp;内容小结</h2><p>弄懂刚刚我讲的这三个例子，开篇的问题也就不言而喻了。我这里总结一下，为什么散列表和链表经常一块使用？</p><p>散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作，但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说，它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据，那我们需要将散列表中的数据拷贝到数组中，然后排序，再遍历。</p><p>因为散列表是动态数据结构，不停地有数据的插入、删除，所以每当我们希望按顺序遍历散列表中的数据的时候，都需要先排序，那效率势必会很低。为了解决这个问题，我们将散列表和链表（或者跳表）结合在一起使用。</p><h2>课后思考</h2><ol>\n<li>\n<p>今天讲的几个散列表和链表结合使用的例子里，我们用的都是双向链表。如果把双向链表改成单链表，还能否正常工作呢？为什么呢？</p>\n</li>\n<li>\n<p>假设猎聘网有10万名猎头，每个猎头都可以通过做任务（比如发布职位）来积累积分，然后通过积分来下载简历。假设你是猎聘网的一名工程师，如何在内存中存储这10万个猎头ID和积分信息，让它能够支持这样几个操作：</p>\n</li>\n</ol><ul>\n<li>\n<p>根据猎头的ID快速查找、删除、更新这个猎头的积分信息；</p>\n</li>\n<li>\n<p>查找积分在某个区间的猎头ID列表；</p>\n</li>\n<li>\n<p>查找按照积分从小到大排名在第x位到第y位之间的猎头ID列表。</p>\n</li>\n</ul><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"20 | 散列表（下）：为什么散列表和链表经常会一起使用？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/7c/ee/7c8fac0957cb7b5987de89d267725eee.mp3",
		"column_id":126,
		"id":64858,
		"site_source_url":"https://time.geekbang.org/column/article/64858"
	},
	{
		"article_content":"<p>通过上一节的学习，我们知道，散列表的查询效率并不能笼统地说成是O(1)。它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好，或者装载因子过高，都可能导致散列冲突发生的概率升高，查询效率下降。</p><p>在极端情况下，有些恶意的攻击者，还有可能通过精心构造的数据，使得所有的数据经过散列函数之后，都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法，那这个时候，散列表就会退化为链表，查询的时间复杂度就从O(1)急剧退化为O(n)。</p><p>如果散列表中有10万个数据，退化后的散列表查询的效率就下降了10万倍。更直接点说，如果之前运行100次查询只需要0.1秒，那现在就需要1万秒。这样就有可能因为查询操作消耗大量CPU或者线程资源，导致系统无法响应其他请求，从而达到拒绝服务攻击（DoS）的目的。这也就是散列表碰撞攻击的基本原理。</p><p>今天，我们就来学习一下，<strong><span class=\"orange\">如何设计一个可以应对各种异常情况的工业级散列表，来避免在散列冲突的情况下，散列表性能的急剧下降，并且能抵抗散列碰撞攻击？</span></strong></p><h2>如何设计散列函数？</h2><p>散列函数设计的好坏，决定了散列表冲突的概率大小，也直接决定了散列表的性能。那什么才是好的散列函数呢？</p><p>首先，<strong>散列函数<strong><strong>的</strong></strong>设计不能太复杂</strong>。过于复杂的散列函数，势必会消耗很多计算时间，也就间接的影响到散列表的性能。其次，<strong>散列函数生成的值要尽可能随机并且均匀分布</strong>，这样才能避免或者最小化散列冲突，而且即便出现冲突，散列到每个槽里的数据也会比较平均，不会出现某个槽内数据特别多的情况。</p><!-- [[[read_end]]] --><p>实际工作中，我们还需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。散列函数各式各样，我举几个常用的、简单的散列函数的设计方法，让你有个直观的感受。</p><p>第一个例子就是我们上一节的学生运动会的例子，我们通过分析参赛编号的特征，把编号中的后两位作为散列值。我们还可以用类似的散列函数处理手机号码，因为手机号码前几位重复的可能性很大，但是后面几位就比较随机，我们可以取手机号的后四位作为散列值。这种散列函数的设计方法，我们一般叫作“数据分析法”。</p><p>第二个例子就是上一节的开篇思考题，如何实现Word拼写检查功能。这里面的散列函数，我们就可以这样设计：将单词中每个字母的<a href=\"http://www.96yx.com/tool/ASC2.htm\">ASCll码</a><a href=\"http://www.96yx.com/tool/ASC2.htm\">值</a>“进位”相加，然后再跟散列表的大小求余、取模，作为散列值。比如，英文单词nice，我们转化出来的散列值就是下面这样：</p><pre><code>hash(&quot;nice&quot;)=((&quot;n&quot; - &quot;a&quot;) * 26*26*26 + (&quot;i&quot; - &quot;a&quot;)*26*26 + (&quot;c&quot; - &quot;a&quot;)*26+ (&quot;e&quot;-&quot;a&quot;)) / 78978\n</code></pre><p>实际上，散列函数的设计方法还有很多，比如直接寻址法、平方取中法、折叠法、随机数法等，这些你只要了解就行了，不需要全都掌握。</p><h2>装载因子过大了怎么办？</h2><p>我们上一节讲到散列表的装载因子的时候说过，装载因子越大，说明散列表中的元素越多，空闲位置越少，散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链，查找的过程也会因此变得很慢。</p><p>对于没有频繁插入和删除的静态数据集合来说，我们很容易根据数据的特点、分布等，设计出完美的、极少冲突的散列函数，因为毕竟之前数据都是已知的。</p><p>对于动态散列表来说，数据集合是频繁变动的，我们事先无法预估将要加入的数据个数，所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入，装载因子就会慢慢变大。当装载因子大到一定程度之后，散列冲突就会变得不可接受。这个时候，我们该如何处理呢？</p><p>还记得我们前面多次讲的“动态扩容”吗？你可以回想一下，我们是如何做数组、栈、队列的动态扩容的。</p><p>针对散列表，当装载因子过大时，我们也可以进行动态扩容，重新申请一个更大的散列表，将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8，那经过扩容之后，新散列表的装载因子就下降为原来的一半，变成了0.4。</p><p>针对数组的扩容，数据搬移操作比较简单。但是，针对散列表的扩容，数据搬移操作要复杂很多。因为散列表的大小变了，数据的存储位置也变了，所以我们需要通过散列函数重新计算每个数据的存储位置。</p><p>你可以看我图里这个例子。在原来的散列表中，21这个元素原来存储在下标为0的位置，搬移到新的散列表中，存储在下标为7的位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/67/43/67d12e07a7d673a9c1d14354ad029443.jpg\" alt=\"\"></p><p>对于支持动态扩容的散列表，插入操作的时间复杂度是多少呢？前面章节我已经多次分析过支持动态扩容的数组、栈等数据结构的时间复杂度了。所以，这里我就不啰嗦了，你要是还不清楚的话，可以回去复习一下。</p><p>插入一个数据，最好情况下，不需要扩容，最好时间复杂度是O(1)。最坏情况下，散列表装载因子过高，启动扩容，我们需要重新申请内存空间，重新计算哈希位置，并且搬移数据，所以时间复杂度是O(n)。用摊还分析法，均摊情况下，时间复杂度接近最好情况，就是O(1)。</p><p>实际上，对于动态散列表，随着数据的删除，散列表中的数据会越来越少，空闲空间会越来越多。如果我们对空间消耗非常敏感，我们可以在装载因子小于某个值之后，启动动态缩容。当然，如果我们更加在意执行效率，能够容忍多消耗一点内存空间，那就可以不用费劲来缩容了。</p><p>我们前面讲到，当散列表的装载因子超过某个阈值时，就需要进行扩容。装载因子阈值需要选择得当。如果太大，会导致冲突过多；如果太小，会导致内存浪费严重。</p><p>装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张，对执行效率要求很高，可以降低负载因子的阈值；相反，如果内存空间紧张，对执行效率要求又不高，可以增加负载因子的值，甚至可以大于1。</p><h2>如何避免低效地扩容？</h2><p>我们刚刚分析得到，大部分情况下，动态扩容的散列表插入一个数据都很快，但是在特殊情况下，当装载因子已经到达阈值，需要先进行扩容，再插入数据。这个时候，插入数据就会变得很慢，甚至会无法接受。</p><p>我举一个极端的例子，如果散列表当前大小为1GB，要想扩容为原来的两倍大小，那就需要对1GB的数据重新计算哈希值，并且从原来的散列表搬移到新的散列表，听起来就很耗时，是不是？</p><p>如果我们的业务代码直接服务于用户，尽管大部分情况下，插入一个数据的操作都很快，但是，极个别非常慢的插入操作，也会让用户崩溃。这个时候，“一次性”扩容的机制就不合适了。</p><p>为了解决一次性扩容耗时过多的情况，我们可以将扩容操作穿插在插入操作的过程中，分批完成。当装载因子触达阈值之后，我们只申请新空间，但并不将老的数据搬移到新散列表中。</p><p>当有新数据要插入时，我们将新数据插入新散列表中，并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表，我们都重复上面的过程。经过多次插入操作之后，老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移，插入操作就都变得很快了。</p><p><img src=\"https://static001.geekbang.org/resource/image/6d/cb/6d6736f986ec4b75dabc5472965fb9cb.jpg\" alt=\"\"></p><p>这期间的查询操作怎么来做呢？对于查询操作，为了兼容了新、老散列表中的数据，我们先从新散列表中查找，如果没有找到，再去老的散列表中查找。</p><p>通过这样均摊的方法，将一次性扩容的代价，均摊到多次插入操作中，就避免了一次性扩容耗时过多的情况。这种实现方式，任何情况下，插入一个数据的时间复杂度都是O(1)。</p><h2>如何选择冲突解决方法？</h2><p>上一节我们讲了两种主要的散列冲突的解决办法，开放寻址法和链表法。这两种冲突解决办法在实际的软件开发中都非常常用。比如，Java中LinkedHashMap就采用了链表法解决冲突，ThreadLocalMap是通过线性探测的开放寻址法来解决冲突。那你知道，这两种冲突解决方法各有什么优势和劣势，又各自适用哪些场景吗？</p><h3>1.开放寻址法</h3><p>我们先来看看，开放寻址法的优点有哪些。</p><p>开放寻址法不像链表法，需要拉很多链表。散列表中的数据都存储在数组中，可以有效地利用CPU缓存加快查询速度。而且，这种方法实现的散列表，序列化起来比较简单。链表法包含指针，序列化起来就没那么容易。你可不要小看序列化，很多场合都会用到的。我们后面就有一节会讲什么是数据结构序列化、如何序列化，以及为什么要序列化。</p><p>我们再来看下，开放寻址法有哪些缺点。</p><p>上一节我们讲到，用开放寻址法解决冲突的散列表，删除数据的时候比较麻烦，需要特殊标记已经删除掉的数据。而且，在开放寻址法中，所有的数据都存储在一个数组中，比起链表法来说，冲突的代价更高。所以，使用开放寻址法解决冲突的散列表，装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。</p><p>所以，<strong>我总结一下，当数据量比较小、装载因子小的时候，适合采用开放寻址法。这也是Java中的<strong><strong>ThreadLocalMap</strong></strong>使用开放寻址法解决散列冲突的原因</strong>。</p><h3>2.链表法</h3><p>首先，链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建，并不需要像开放寻址法那样事先申请好。实际上，这一点也是我们前面讲过的链表优于数组的地方。</p><p>链表法比起开放寻址法，对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1时，就可能会有大量的散列冲突，导致大量的探测、再散列等，性能会下降很多。但是对于链表法来说，只要散列函数的值随机均匀，即便装载因子变成10，也就是链表的长度变长了而已，虽然查找效率有所下降，但是比起顺序查找还是快很多。</p><p>还记得我们之前在链表那一节讲的吗？链表因为要存储指针，所以对于比较小的对象的存储，是比较消耗内存的，还有可能会让内存的消耗翻倍。而且，因为链表中的结点是零散分布在内存中的，不是连续的，所以对CPU缓存是不友好的，这方面对于执行效率也有一定的影响。</p><p>当然，如果我们存储的是大对象，也就是说要存储的对象的大小远远大于一个指针的大小（4个字节或者8个字节），那链表中指针的内存消耗在大对象面前就可以忽略了。</p><p>实际上，我们对链表法稍加改造，可以实现一个更加高效的散列表。那就是，我们将链表法中的链表改造为其他高效的动态数据结构，比如跳表、红黑树。这样，即便出现散列冲突，极端情况下，所有的数据都散列到同一个桶内，那最终退化成的散列表的查找时间也只不过是O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。</p><p><img src=\"https://static001.geekbang.org/resource/image/10/29/103b84d7173277c5565607b413c40129.jpg\" alt=\"\"></p><p>所以，<strong>我总结一下，基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表，而且，比起开放寻址法，它更加灵活，支持更多的优化策略，比如用红黑树代替链表</strong>。</p><h2>工业级散列表举例分析</h2><p>刚刚我讲了实现一个工业级散列表需要涉及的一些关键技术，现在，我就拿一个具体的例子，Java中的HashMap这样一个工业级的散列表，来具体看下，这些技术是怎么应用的。</p><h3>1.初始大小</h3><p>HashMap默认的初始大小是16，当然这个默认值是可以设置的，如果事先知道大概的数据量有多大，可以通过修改默认初始大小，减少动态扩容的次数，这样会大大提高HashMap的性能。</p><h3>2.装载因子和动态扩容</h3><p>最大装载因子默认是0.75，当HashMap中元素个数超过0.75*capacity（capacity表示散列表的容量）的时候，就会启动扩容，每次扩容都会扩容为原来的两倍大小。</p><h3>3.散列冲突解决方法</h3><p>HashMap底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理，也免不了会出现拉链过长的情况，一旦出现拉链过长，则会严重影响HashMap的性能。</p><p>于是，在JDK1.8版本中，为了对HashMap做进一步优化，我们引入了红黑树。而当链表长度太长（默认超过8）时，链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点，提高HashMap的性能。当红黑树结点个数少于8个的时候，又会将红黑树转化为链表。因为在数据量较小的情况下，红黑树要维护平衡，比起链表来，性能上的优势并不明显。</p><h3>4.散列函数</h3><p>散列函数的设计并不复杂，追求的是简单高效、分布均匀。我把它摘抄出来，你可以看看。</p><pre><code>int hash(Object key) {\n    int h = key.hashCode()；\n    return (h ^ (h &gt;&gt;&gt; 16)) &amp; (capitity -1); //capicity表示散列表的大小\n}\n</code></pre><p>其中，hashCode()返回的是Java对象的hash code。比如String类型的对象的hashCode()就是下面这样：</p><pre><code>public int hashCode() {\n  int var1 = this.hash;\n  if(var1 == 0 &amp;&amp; this.value.length &gt; 0) {\n    char[] var2 = this.value;\n    for(int var3 = 0; var3 &lt; this.value.length; ++var3) {\n      var1 = 31 * var1 + var2[var3];\n    }\n    this.hash = var1;\n  }\n  return var1;\n}\n</code></pre><h2>解答开篇</h2><p>今天的内容就讲完了，我现在来分析一下开篇的问题：如何设计的一个工业级的散列函数？如果这是一道面试题或者是摆在你面前的实际开发问题，你会从哪几个方面思考呢？</p><p>首先，我会思考，<strong>何为一个工业级的散列表？工业级的散列表<strong><strong>应该</strong></strong>具有哪些特性？</strong></p><p>结合已经学习过的散列知识，我觉得应该有这样几点要求：</p><ul>\n<li>\n<p>支持快速的查询、插入、删除操作；</p>\n</li>\n<li>\n<p>内存占用合理，不能浪费过多的内存空间；</p>\n</li>\n<li>\n<p>性能稳定，极端情况下，散列表的性能也不会退化到无法接受的情况。</p>\n</li>\n</ul><p><strong>如何实现这样一个散列表呢？</strong>根据前面讲到的知识，我会从这三个方面来考虑设计思路：</p><ul>\n<li>\n<p>设计一个合适的散列函数；</p>\n</li>\n<li>\n<p>定义装载因子阈值，并且设计动态扩容策略；</p>\n</li>\n<li>\n<p>选择合适的散列冲突解决方法。</p>\n</li>\n</ul><p>关于散列函数、装载因子、动态扩容策略，还有散列冲突的解决办法，我们前面都讲过了，具体如何选择，还要结合具体的业务场景、具体的业务数据来具体分析。不过只要我们朝这三个方向努力，就离设计出工业级的散列表不远了。</p><h2>内容小结</h2><p>上一节的内容比较偏理论，今天的内容侧重实战。我主要讲了如何设计一个工业级的散列表，以及如何应对各种异常情况，防止在极端情况下，散列表的性能退化过于严重。我分了三部分来讲解这些内容，分别是：如何设计散列函数，如何根据装载因子动态扩容，以及如何选择散列冲突解决方法。</p><p>关于散列函数的设计，我们要尽可能让散列后的值随机且均匀分布，这样会尽可能地减少散列冲突，即便冲突之后，分配到每个槽内的数据也比较均匀。除此之外，散列函数的设计也不能太复杂，太复杂就会太耗时间，也会影响散列表的性能。</p><p>关于散列冲突解决方法的选择，我对比了开放寻址法和链表法两种方法的优劣和适应的场景。大部分情况下，链表法更加普适。而且，我们还可以通过将链表法中的链表改造成其他动态查找数据结构，比如红黑树，来避免散列表时间复杂度退化成O(n)，抵御散列碰撞攻击。但是，对于小规模数据、装载因子不高的散列表，比较适合用开放寻址法。</p><p>对于动态散列表来说，不管我们如何设计散列函数，选择什么样的散列冲突解决方法。随着数据的不断增加，散列表总会出现装载因子过高的情况。这个时候，我们就需要启动动态扩容。</p><h2>课后思考</h2><p>在你熟悉的编程语言中，哪些数据类型底层是基于散列表实现的？散列函数是如何设计的？散列冲突是通过哪种方法解决的？是否支持动态扩容呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"19 | 散列表（中）：如何打造一个工业级水平的散列表？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/f4/dc/f4eff56cf16d3be0d71d6f53da8187dc.mp3",
		"column_id":126,
		"id":64586,
		"site_source_url":"https://time.geekbang.org/column/article/64586"
	},
	{
		"article_content":"<p>Word这种文本编辑器你平时应该经常用吧，那你有没有留意过它的拼写检查功能呢？一旦我们在Word里输入一个错误的英文单词，它就会用标红的方式提示“拼写错误”。<strong><span class=\"orange\">Word的这个单词拼写检查功能，虽然很小但却非常实用。你有没有想过，这个功能是如何实现的呢？</span></strong></p><p>其实啊，一点儿都不难。只要你学完今天的内容，<strong>散列表</strong>（Hash Table）。你就能像微软Office的工程师一样，轻松实现这个功能。</p><h2>散列思想</h2><p>散列表的英文叫“Hash Table”，我们平时也叫它“哈希表”或者“Hash表”，你一定也经常听过它，我在前面的文章里，也不止一次提到过，但是你是不是真的理解这种数据结构呢？</p><p><strong>散列表用的是数组支持按照下标随机访问数据的特性，所以散列表其实就是数组的一种扩展，由数组演化而来。可以说，如果没有数组，就没有散列表。</strong></p><p>我用一个例子来解释一下。假如我们有89名选手参加学校运动会。为了方便记录成绩，每个选手胸前都会贴上自己的参赛号码。这89名选手的编号依次是1到89。现在我们希望编程实现这样一个功能，通过编号快速找到对应的选手信息。你会怎么做呢？</p><p>我们可以把这89名选手的信息放在数组里。编号为1的选手，我们放到数组中下标为1的位置；编号为2的选手，我们放到数组中下标为2的位置。以此类推，编号为k的选手放到数组中下标为k的位置。</p><!-- [[[read_end]]] --><p>因为参赛编号跟数组下标一一对应，当我们需要查询参赛编号为x的选手的时候，我们只需要将下标为x的数组元素取出来就可以了，时间复杂度就是O(1)。这样按照编号查找选手信息，效率是不是很高？</p><p>实际上，这个例子已经用到了散列的思想。在这个例子里，参赛编号是自然数，并且与数组的下标形成一一映射，所以利用数组支持根据下标随机访问的时候，时间复杂度是O(1)这一特性，就可以实现快速查找编号对应的选手信息。</p><p>你可能要说了，这个例子中蕴含的散列思想还不够明显，那我来改造一下这个例子。</p><p>假设校长说，参赛编号不能设置得这么简单，要加上年级、班级这些更详细的信息，所以我们把编号的规则稍微修改了一下，用6位数字来表示。比如051167，其中，前两位05表示年级，中间两位11表示班级，最后两位还是原来的编号1到89。这个时候我们该如何存储选手信息，才能够支持通过编号来快速查找选手信息呢？</p><p>思路还是跟前面类似。尽管我们不能直接把编号作为数组下标，但我们可以截取参赛编号的后两位作为数组下标，来存取选手信息数据。当通过参赛编号查询选手信息的时候，我们用同样的方法，取参赛编号的后两位，作为数组下标，来读取数组中的数据。</p><p>这就是典型的散列思想。其中，参赛选手的编号我们叫作<strong>键</strong>（key）或者<strong>关键字</strong>。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫作<strong>散列函数</strong>（或“Hash函数”“哈希函数”），而散列函数计算得到的值就叫作<strong>散列值</strong>（或“Hash值”“哈希值”）。</p><p><img src=\"https://static001.geekbang.org/resource/image/92/73/92c89a57e21f49d2f14f4424343a2773.jpg\" alt=\"\"></p><p>通过这个例子，我们可以总结出这样的规律：散列表用的就是数组支持按照下标随机访问的时候，时间复杂度是O(1)的特性。我们通过散列函数把元素的键值映射为下标，然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时，我们用同样的散列函数，将键值转化数组下标，从对应的数组下标的位置取数据。</p><h2>散列函数</h2><p>从上面的例子我们可以看到，散列函数在散列表中起着非常关键的作用。现在我们就来学习下散列函数。</p><p>散列函数，顾名思义，它是一个函数。我们可以把它定义成<strong>hash(key)</strong>，其中key表示元素的键值，hash(key)的值表示经过散列函数计算得到的散列值。</p><p>那第一个例子中，编号就是数组下标，所以hash(key)就等于key。改造后的例子，写成散列函数稍微有点复杂。我用伪代码将它写成函数就是下面这样：</p><pre><code>int hash(String key) {\n  // 获取后两位字符\n  string lastTwoChars = key.substr(length-2, length);\n  // 将后两位字符转换为整数\n  int hashValue = convert lastTwoChas to int-type;\n  return hashValue;\n}\n</code></pre><p>刚刚举的学校运动会的例子，散列函数比较简单，也比较容易想到。但是，如果参赛选手的编号是随机生成的6位数字，又或者用的是a到z之间的字符串，<strong>该如何构造散列函数呢？<strong>我总结了三点</strong>散列函数<strong><strong>设计的</strong></strong>基本要求</strong>：</p><ol>\n<li>\n<p>散列函数计算得到的散列值是一个非负整数；</p>\n</li>\n<li>\n<p>如果key1 = key2，那hash(key1) == hash(key2)；</p>\n</li>\n<li>\n<p>如果key1 ≠ key2，那hash(key1) ≠ hash(key2)。</p>\n</li>\n</ol><p>我来解释一下这三点。其中，第一点理解起来应该没有任何问题。因为数组下标是从0开始的，所以散列函数生成的散列值也要是非负整数。第二点也很好理解。相同的key，经过散列函数得到的散列值也应该是相同的。</p><p>第三点理解起来可能会有问题，我着重说一下。这个要求看起来合情合理，但是在真实的情况下，要想找到一个不同的key对应的散列值都不一样的散列函数，几乎是不可能的。即便像业界著名的<a href=\"https://zh.wikipedia.org/wiki/MD5\">MD5</a>、<a href=\"https://zh.wikipedia.org/wiki/SHA%E5%AE%B6%E6%97%8F\">SHA</a>、<a href=\"https://zh.wikipedia.org/wiki/%E5%BE%AA%E7%92%B0%E5%86%97%E9%A4%98%E6%A0%A1%E9%A9%97\">CRC</a>等哈希算法，也无法完全避免这种<strong>散列冲突</strong>。而且，因为数组的存储空间有限，也会加大散列冲突的概率。</p><p>所以我们几乎无法找到一个完美的无冲突的散列函数，即便能找到，付出的时间成本、计算成本也是很大的，所以针对散列冲突问题，我们需要通过其他途径来解决。</p><h2>散列冲突</h2><p>再好的散列函数也无法避免散列冲突。那究竟该如何解决散列冲突问题呢？我们常用的散列冲突解决方法有两类，开放寻址法（open addressing）和链表法（chaining）。</p><h3>1.开放寻址法</h3><p>开放寻址法的核心思想是，如果出现了散列冲突，我们就重新探测一个空闲位置，将其插入。那如何重新探测新的位置呢？我先讲一个比较简单的探测方法，<strong>线性探测</strong>（Linear Probing）。</p><p>当我们往散列表中插入数据时，如果某个数据经过散列函数散列之后，存储位置已经被占用了，我们就从当前位置开始，依次往后查找，看是否有空闲位置，直到找到为止。</p><p>我说的可能比较抽象，我举一个例子具体给你说明一下。这里面黄色的色块表示空闲位置，橙色的色块表示已经存储了数据。</p><p><img src=\"https://static001.geekbang.org/resource/image/5c/d5/5c31a3127cbc00f0c63409bbe1fbd0d5.jpg\" alt=\"\"></p><p>从图中可以看出，散列表的大小为10，在元素x插入散列表之前，已经6个元素插入到散列表中。x经过Hash算法之后，被散列到位置下标为7的位置，但是这个位置已经有数据了，所以就产生了冲突。于是我们就顺序地往后一个一个找，看有没有空闲的位置，遍历到尾部都没有找到空闲的位置，于是我们再从表头开始找，直到找到空闲位置2，于是将其插入到这个位置。</p><p>在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值，然后比较数组中下标为散列值的元素和要查找的元素。如果相等，则说明就是我们要找的元素；否则就顺序往后依次查找。如果遍历到数组中的空闲位置，还没有找到，就说明要查找的元素并没有在散列表中。</p><p><img src=\"https://static001.geekbang.org/resource/image/91/ff/9126b0d33476777e7371b96e676e90ff.jpg\" alt=\"\"></p><p>散列表跟数组一样，不仅支持插入、查找操作，还支持删除操作。对于使用线性探测法解决冲突的散列表，删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。这是为什么呢？</p><p>还记得我们刚讲的查找操作吗？在查找的时候，一旦我们通过线性探测方法，找到一个空闲位置，我们就可以认定散列表中不存在这个数据。但是，如果这个空闲位置是我们后来删除的，就会导致原来的查找算法失效。本来存在的数据，会被认定为不存在。这个问题如何解决呢？</p><p>我们可以将删除的元素，特殊标记为deleted。当线性探测查找的时候，遇到标记为deleted的空间，并不是停下来，而是继续往下探测。</p><p><img src=\"https://static001.geekbang.org/resource/image/fe/1d/fe7482ba09670cbe05a9dfe4dd49bd1d.jpg\" alt=\"\"></p><p>你可能已经发现了，线性探测法其实存在很大问题。当散列表中插入的数据越来越多时，散列冲突发生的可能性就会越来越大，空闲位置会越来越少，线性探测的时间就会越来越久。极端情况下，我们可能需要探测整个散列表，所以最坏情况下的时间复杂度为O(n)。同理，在删除和查找时，也有可能会线性探测整张散列表，才能找到要查找或者删除的数据。</p><p>对于开放寻址冲突解决方法，除了线性探测方法之外，还有另外两种比较经典的探测方法，<strong>二次探测</strong>（Quadratic probing）和<strong>双重散列</strong>（Double hashing）。</p><p>所谓二次探测，跟线性探测很像，线性探测每次探测的步长是1，那它探测的下标序列就是hash(key)+0，hash(key)+1，hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”，也就是说，它探测的下标序列就是hash(key)+0，hash(key)+1<sup>2</sup>，hash(key)+2<sup>2</sup>……</p><p>所谓双重散列，意思就是不仅要使用一个散列函数。我们使用一组散列函数hash1(key)，hash2(key)，hash3(key)……我们先用第一个散列函数，如果计算得到的存储位置已经被占用，再用第二个散列函数，依次类推，直到找到空闲的存储位置。</p><p>不管采用哪种探测方法，当散列表中空闲位置不多的时候，散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率，一般情况下，我们会尽可能保证散列表中有一定比例的空闲槽位。我们用<strong>装载因子</strong>（load factor）来表示空位的多少。</p><p>装载因子的计算公式是：</p><pre><code>散列表的装载因子=填入表中的元素个数/散列表的长度\n</code></pre><p>装载因子越大，说明空闲位置越少，冲突越多，散列表的性能会下降。</p><h3>2.链表法</h3><p>链表法是一种更加常用的散列冲突解决办法，相比开放寻址法，它要简单很多。我们来看这个图，在散列表中，每个“桶（bucket）”或者“槽（slot）”会对应一条链表，所有散列值相同的元素我们都放到相同槽位对应的链表中。</p><p><img src=\"https://static001.geekbang.org/resource/image/a4/7f/a4b77d593e4cb76acb2b0689294ec17f.jpg\" alt=\"\"></p><p>当插入的时候，我们只需要通过散列函数计算出对应的散列槽位，将其插入到对应链表中即可，所以插入的时间复杂度是O(1)。当查找、删除一个元素时，我们同样通过散列函数计算出对应的槽，然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢？</p><p>实际上，这两个操作的时间复杂度跟链表的长度k成正比，也就是O(k)。对于散列比较均匀的散列函数来说，理论上讲，k=n/m，其中n表示散列中数据的个数，m表示散列表中“槽”的个数。</p><h2>解答开篇</h2><p>有了前面这些基本知识储备，我们来看一下开篇的思考题：Word文档中单词拼写检查功能是如何实现的？</p><p>常用的英文单词有20万个左右，假设单词的平均长度是10个字母，平均一个单词占用10个字节的内存空间，那20万英文单词大约占2MB的存储空间，就算放大10倍也就是20MB。对于现在的计算机来说，这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。</p><p>当用户输入某个英文单词时，我们拿用户输入的单词去散列表中查找。如果查到，则说明拼写正确；如果没有查到，则说明拼写可能有误，给予提示。借助散列表这种数据结构，我们就可以轻松实现快速判断是否存在拼写错误。</p><h2>内容小结</h2><p>今天我讲了一些比较基础、比较偏理论的散列表知识，包括散列表的由来、散列函数、散列冲突的解决方法。</p><p>散列表来源于数组，它借助散列函数对数组这种数据结构进行扩展，利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是<strong>散列函数设计</strong>和<strong>散列冲突解决</strong>。散列冲突有两种常用的解决方法，开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率，也就决定散列表的性能。</p><p>针对散列函数和散列冲突，今天我只讲了一些基础的概念、方法，下一节我会更贴近实战、更加深入探讨这两个问题。</p><h2>课后思考</h2><ol>\n<li>\n<p>假设我们有10万条URL访问日志，如何按照访问次数给URL排序？</p>\n</li>\n<li>\n<p>有两个字符串数组，每个数组大约有10万条字符串，如何快速找出两个数组中相同的字符串？</p>\n</li>\n</ol><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"18 | 散列表（上）：Word文档中的单词拼写检查功能是如何实现的？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/b6/b9/b616f298218527e6d714abbf8e479fb9.mp3",
		"column_id":126,
		"id":64233,
		"site_source_url":"https://time.geekbang.org/column/article/64233"
	},
	{
		"article_content":"<p>上两节我们讲了二分查找算法。当时我讲到，因为二分查找底层依赖的是数组随机访问的特性，所以只能用数组来实现。如果数据存储在链表中，就真的没法用二分查找算法了吗？</p><p>实际上，我们只需要对链表稍加改造，就可以支持类似“二分”的查找算法。我们把改造之后的数据结构叫作<strong>跳表</strong>（Skip list），也就是今天要讲的内容。</p><p>跳表这种数据结构对你来说，可能会比较陌生，因为一般的数据结构和算法书籍里都不怎么会讲。但是它确实是一种各方面性能都比较优秀的<strong>动态数据结构</strong>，可以支持快速的插入、删除、查找操作，写起来也不复杂，甚至可以替代<a href=\"https://zh.wikipedia.org/wiki/%E7%BA%A2%E9%BB%91%E6%A0%91\">红黑树</a>（Red-black tree）。</p><p>Redis中的有序集合（Sorted Set）就是用跳表来实现的。如果你有一定基础，应该知道红黑树也可以实现快速的插入、删除和查找操作。<strong><span class=\"orange\">那Redis为什么会选择用跳表来实现有序集合呢？</span></strong> 为什么不用红黑树呢？学完今天的内容，你就知道答案了。</p><h2>如何理解“跳表”？</h2><p>对于一个单链表来讲，即便链表中存储的数据是有序的，如果我们要想在其中查找某个数据，也只能从头到尾遍历链表。这样查找效率就会很低，时间复杂度会很高，是O(n)。</p><p><img src=\"https://static001.geekbang.org/resource/image/e1/6d/e18303fcedc068e5a168de04df956f6d.jpg\" alt=\"\"></p><p>那怎么来提高查找效率呢？如果像图中那样，对链表建立一级“索引”，查找起来是不是就会更快一些呢？每两个结点提取一个结点到上一级，我们把抽出来的那一级叫作<strong>索引</strong>或<strong>索引层</strong>。你可以看我画的图。图中的down表示down指针，指向下一级结点。</p><!-- [[[read_end]]] --><p><img src=\"https://static001.geekbang.org/resource/image/14/8e/14753c824a5ee4a976ea799727adc78e.jpg\" alt=\"\"></p><p>如果我们现在要查找某个结点，比如16。我们可以先在索引层遍历，当遍历到索引层中值为13的结点时，我们发现下一个结点是17，那要查找的结点16肯定就在这两个结点之间。然后我们通过索引层结点的down指针，下降到原始链表这一层，继续遍历。这个时候，我们只需要再遍历2个结点，就可以找到值等于16的这个结点了。这样，原来如果要查找16，需要遍历10个结点，现在只需要遍历7个结点。</p><p>从这个例子里，我们看出，<strong>加来一层索引之后，查找一个结点需要遍历的结点个数减少了，也就是说查找效率提高了</strong>。那如果我们再加一级索引呢？效率会不会提升更多呢？</p><p>跟前面建立第一级索引的方式相似，我们在第一级索引的基础之上，每两个结点就抽出一个结点到第二级索引。现在我们再来查找16，只需要遍历6个结点了，需要遍历的结点数量又减少了。</p><p><img src=\"https://static001.geekbang.org/resource/image/49/65/492206afe5e2fef9f683c7cff83afa65.jpg\" alt=\"\"></p><p>我举的例子数据量不大，所以即便加了两级索引，查找效率的提升也并不明显。为了让你能真切地感受索引提升查询效率。我画了一个包含64个结点的链表，按照前面讲的这种思路，建立了五级索引。</p><p><img src=\"https://static001.geekbang.org/resource/image/46/a9/46d283cd82c987153b3fe0c76dfba8a9.jpg\" alt=\"\"></p><p>从图中我们可以看出，原来没有索引的时候，查找62需要遍历62个结点，现在只需要遍历11个结点，速度是不是提高了很多？所以，当链表的长度n比较大时，比如1000、10000的时候，在构建索引之后，查找效率的提升就会非常明显。</p><p>前面讲的<strong>这种链表<strong><strong>加</strong></strong>多<strong><strong>级</strong></strong>索引的结构，就是跳表</strong>。我通过例子给你展示了跳表是如何减少查询次数的，现在你应该比较清晰地知道，跳表确实是可以提高查询效率的。接下来，我会定量地分析一下，用跳表查询到底有多快。</p><h2>用跳表查询到底有多快？</h2><p>前面我讲过，算法的执行效率可以通过时间复杂度来度量，这里依旧可以用。我们知道，在一个单链表中查询某个数据的时间复杂度是O(n)。那在一个具有多级索引的跳表中，查询某个数据的时间复杂度是多少呢？</p><p>这个时间复杂度的分析方法比较难想到。我把问题分解一下，先来看这样一个问题，如果链表里有n个结点，会有多少级索引呢？</p><p>按照我们刚才讲的，每两个结点会抽出一个结点作为上一级索引的结点，那第一级索引的结点个数大约就是n/2，第二级索引的结点个数大约就是n/4，第三级索引的结点个数大约就是n/8，依次类推，也就是说，<strong>第k级索引的结点个数是第k-1级索引的结点个数的1/2，<strong><strong>那</strong></strong>第k<strong><strong>级</strong></strong>索引结点的个数就是n/(2<sup>k</sup>)。</strong></p><p>假设索引有h级，最高级的索引有2个结点。通过上面的公式，我们可以得到n/(2<sup>h</sup>)=2，从而求得h=log<sub>2</sub>n-1。如果包含原始链表这一层，整个跳表的高度就是log<sub>2</sub>n。我们在跳表中查询某个数据的时候，如果每一层都要遍历m个结点，那在跳表中查询一个数据的时间复杂度就是O(m*logn)。</p><p>那这个m的值是多少呢？按照前面这种索引结构，我们每一级索引都最多只需要遍历3个结点，也就是说m=3，为什么是3呢？我来解释一下。</p><p>假设我们要查找的数据是x，在第k级索引中，我们遍历到y结点之后，发现x大于y，小于后面的结点z，所以我们通过y的down指针，从第k级索引下降到第k-1级索引。在第k-1级索引中，y和z之间只有3个结点（包含y和z），所以，我们在K-1级索引中最多只需要遍历3个结点，依次类推，每一级索引都最多只需要遍历3个结点。</p><p><img src=\"https://static001.geekbang.org/resource/image/d0/0c/d03bef9a64a0368e6a0d23ace8bd450c.jpg\" alt=\"\"></p><p>通过上面的分析，我们得到m=3，所以在跳表中查询任意数据的时间复杂度就是O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说，我们其实是基于单链表实现了二分查找，是不是很神奇？不过，天下没有免费的午餐，这种查询效率的提升，前提是建立了很多级索引，也就是我们在<a href=\"https://time.geekbang.org/column/article/41013\">第6节</a>讲过的空间换时间的设计思路。</p><h2>跳表是不是很浪费内存？</h2><p>比起单纯的单链表，跳表需要存储多级索引，肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢？我们来分析一下跳表的空间复杂度。</p><p>跳表的空间复杂度分析并不难，我在前面说了，假设原始链表大小为n，那第一级索引大约有n/2个结点，第二级索引大约有n/4个结点，以此类推，每上升一级就减少一半，直到剩下2个结点。如果我们把每层索引的结点数写出来，就是一个等比数列。</p><p><img src=\"https://static001.geekbang.org/resource/image/10/55/100e9d6e5abeaae542cf7841be3f8255.jpg\" alt=\"\"></p><p>这几级索引的结点总和就是n/2+n/4+n/8…+8+4+2=n-2。所以，跳表的空间复杂度是O(n)。也就是说，如果将包含n个结点的单链表构造成跳表，我们需要额外再用接近n个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢？</p><p>我们前面都是每两个结点抽一个结点到上级索引，如果我们每三个结点或五个结点，抽一个结点到上级索引，是不是就不用那么多索引结点了呢？我画了一个每三个结点抽一个的示意图，你可以看下。</p><p><img src=\"https://static001.geekbang.org/resource/image/0b/f7/0b0680ecf500f9349fc142e1a9eb73f7.jpg\" alt=\"\"></p><p>从图中可以看出，第一级索引需要大约n/3个结点，第二级索引需要大约n/9个结点。每往上一级，索引结点个数都除以3。为了方便计算，我们假设最高一级的索引结点个数是1。我们把每级索引的结点个数都写下来，也是一个等比数列。</p><p><img src=\"https://static001.geekbang.org/resource/image/19/95/192c480664e35591360cee96ff2f8395.jpg\" alt=\"\"></p><p>通过等比数列求和公式，总的索引结点大约就是n/3+n/9+n/27+…+9+3+1=n/2。尽管空间复杂度还是O(n)，但比上面的每两个结点抽一个结点的索引构建方法，要减少了一半的索引结点存储空间。</p><p>实际上，在软件开发中，我们不必太在意索引占用的额外空间。在讲数据结构和算法时，我们习惯性地把要处理的数据看成整数，但是在实际的软件开发中，原始链表中存储的有可能是很大的对象，而索引结点只需要存储关键值和几个指针，并不需要存储对象，所以当对象比索引结点大很多时，那索引占用的额外空间就可以忽略了。</p><h2>高效的动态插入和删除</h2><p>跳表长什么样子我想你应该已经很清楚了，它的查找操作我们刚才也讲过了。实际上，跳表这个动态数据结构，不仅支持查找操作，还支持动态的插入、删除操作，而且插入、删除操作的时间复杂度也是O(logn)。</p><p>我们现在来看下， 如何在跳表中插入一个数据，以及它是如何做到O(logn)的时间复杂度的？</p><p>我们知道，在单链表中，一旦定位好要插入的位置，插入结点的时间复杂度是很低的，就是O(1)。但是，这里为了保证原始链表中数据的有序性，我们需要先找到要插入的位置，这个查找操作就会比较耗时。</p><p>对于纯粹的单链表，需要遍历每个结点，来找到插入的位置。但是，对于跳表来说，我们讲过查找某个结点的的时间复杂度是O(logn)，所以这里查找某个数据应该插入的位置，方法也是类似的，时间复杂度也是O(logn)。我画了一张图，你可以很清晰地看到插入的过程。</p><p><img src=\"https://static001.geekbang.org/resource/image/65/6c/65379f0651bc3a7cfd13ab8694c4d26c.jpg\" alt=\"\"></p><p>好了，我们再来看删除操作。</p><p>如果这个结点在索引中也有出现，我们除了要删除原始链表中的结点，还要删除索引中的。因为单链表中的删除操作需要拿到要删除结点的前驱结点，然后通过指针操作完成删除。所以在查找要删除的结点的时候，一定要获取前驱结点。当然，如果我们用的是双向链表，就不需要考虑这个问题了。</p><h2>跳表索引动态更新</h2><p>当我们不停地往跳表中插入数据时，如果我们不更新索引，就有可能出现某2个索引结点之间数据非常多的情况。极端情况下，跳表还会退化成单链表。</p><p><img src=\"https://static001.geekbang.org/resource/image/c8/c5/c863074c01c26538cf0134eaf8dc67c5.jpg\" alt=\"\"></p><p>作为一种动态数据结构，我们需要某种手段来维护索引与原始链表大小之间的平衡，也就是说，如果链表中结点多了，索引结点就相应地增加一些，避免复杂度退化，以及查找、插入、删除操作性能下降。</p><p>如果你了解红黑树、AVL树这样平衡二叉树，你就知道它们是通过左右旋的方式保持左右子树的大小平衡（如果不了解也没关系，我们后面会讲），而跳表是通过随机函数来维护前面提到的“平衡性”。</p><p>当我们往跳表中插入数据的时候，我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢？</p><p>我们通过一个随机函数，来决定将这个结点插入到哪几级索引中，比如随机函数生成了值K，那我们就将这个结点添加到第一级到第K级这K级索引中。</p><p><img src=\"https://static001.geekbang.org/resource/image/a8/a7/a861445d0b53fc842f38919365b004a7.jpg\" alt=\"\"></p><p>随机函数的选择很有讲究，从概率上来讲，能够保证跳表的索引大小和数据大小平衡性，不至于性能过度退化。至于随机函数的选择，我就不展开讲解了。如果你感兴趣的话，可以看看我在GitHub上的代码或者Redis中关于有序集合的跳表实现。</p><p>跳表的实现还是稍微有点复杂的，我将Java实现的代码放到了GitHub中，你可以根据我刚刚的讲解，对照着代码仔细思考一下。你不用死记硬背代码，跳表的实现并不是我们这节的重点。</p><h2>解答开篇</h2><p>今天的内容到此就讲完了。现在，我来讲解一下开篇的思考题：为什么Redis要用跳表来实现有序集合，而不是红黑树？</p><p>Redis中的有序集合是通过跳表来实现的，严格点讲，其实还用到了散列表。不过散列表我们后面才会讲到，所以我们现在暂且忽略这部分。如果你去查看Redis的开发手册，就会发现，Redis中的有序集合支持的核心操作主要有下面这几个：</p><ul>\n<li>\n<p>插入一个数据；</p>\n</li>\n<li>\n<p>删除一个数据；</p>\n</li>\n<li>\n<p>查找一个数据；</p>\n</li>\n<li>\n<p>按照区间查找数据（比如查找值在[100, 356]之间的数据）；</p>\n</li>\n<li>\n<p>迭代输出有序序列。</p>\n</li>\n</ul><p>其中，插入、删除、查找以及迭代输出有序序列这几个操作，红黑树也可以完成，时间复杂度跟跳表是一样的。但是，按照区间来查找数据这个操作，红黑树的效率没有跳表高。</p><p>对于按照区间查找数据这个操作，跳表可以做到O(logn)的时间复杂度定位区间的起点，然后在原始链表中顺序往后遍历就可以了。这样做非常高效。</p><p>当然，Redis之所以用跳表来实现有序集合，还有其他原因，比如，跳表更容易代码实现。虽然跳表的实现也不简单，但比起红黑树来说还是好懂、好写多了，而简单就意味着可读性好，不容易出错。还有，跳表更加灵活，它可以通过改变索引构建策略，有效平衡执行效率和内存消耗。</p><p>不过，跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些，很多编程语言中的Map类型都是通过红黑树来实现的。我们做业务开发的时候，直接拿来用就可以了，不用费劲自己去实现一个红黑树，但是跳表并没有一个现成的实现，所以在开发中，如果你想使用跳表，必须要自己实现。</p><h2>内容小结</h2><p>今天我们讲了跳表这种数据结构。跳表使用空间换时间的设计思路，通过构建多级索引来提高查询的效率，实现了基于链表的“二分查找”。跳表是一种动态数据结构，支持快速的插入、删除、查找操作，时间复杂度都是O(logn)。</p><p>跳表的空间复杂度是O(n)。不过，跳表的实现非常灵活，可以通过改变索引构建策略，有效平衡执行效率和内存消耗。虽然跳表的代码实现并不简单，但是作为一种动态数据结构，比起红黑树来说，实现要简单多了。所以很多时候，我们为了代码的简单、易读，比起红黑树，我们更倾向用跳表。</p><h2>课后思考</h2><p>在今天的内容中，对于跳表的时间复杂度分析，我分析了每两个结点提取一个结点作为索引的时间复杂度。如果每三个或者五个结点提取一个结点作为上级索引，对应的在跳表中查询数据的时间复杂度是多少呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"17 | 跳表：为什么Redis一定要用跳表来实现有序集合？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/88/eb/88ff2776cade282ff5ee9e9575e444eb.mp3",
		"column_id":126,
		"id":42896,
		"site_source_url":"https://time.geekbang.org/column/article/42896"
	},
	{
		"article_content":"<p>通过IP地址来查找IP归属地的功能，不知道你有没有用过？没用过也没关系，你现在可以打开百度，在搜索框里随便输一个IP地址，就会看到它的归属地。</p><p><img src=\"https://static001.geekbang.org/resource/image/c4/0a/c497770eca94fdf3baf4f813bafcb20a.jpg\" alt=\"\"></p><p>这个功能并不复杂，它是通过维护一个很大的IP地址库来实现的。地址库中包括IP地址范围和归属地的对应关系。</p><p>当我们想要查询202.102.133.13这个IP地址的归属地时，我们就在地址库中搜索，发现这个IP地址落在[202.102.133.0, 202.102.133.255]这个地址范围内，那我们就可以将这个IP地址范围对应的归属地“山东东营市”显示给用户了。</p><pre><code>[202.102.133.0, 202.102.133.255]  山东东营市 \n[202.102.135.0, 202.102.136.255]  山东烟台 \n[202.102.156.34, 202.102.157.255] 山东青岛 \n[202.102.48.0, 202.102.48.255] 江苏宿迁 \n[202.102.49.15, 202.102.51.251] 江苏泰州 \n[202.102.56.0, 202.102.56.255] 江苏连云港\n</code></pre><p>现在我的问题是，在庞大的地址库中逐一比对IP地址所在的区间，是非常耗时的。<strong><span class=\"orange\">假设我们有12万条这样的IP区间与归属地的对应关系，如何快速定位出一个IP地址的归属地呢？</span></strong></p><p>是不是觉得比较难？不要紧，等学完今天的内容，你就会发现这个问题其实很简单。</p><p>上一节我讲了二分查找的原理，并且介绍了最简单的一种二分查找的代码实现。今天我们来讲几种二分查找的变形问题。</p><p>不知道你有没有听过这样一个说法：“十个二分九个错”。二分查找虽然原理极其简单，但是想要写出没有Bug的二分查找并不容易。</p><p>唐纳德·克努特（Donald E.Knuth）在《计算机程序设计艺术》的第3卷《排序和查找》中说到：“尽管第一个二分查找算法于1946年出现，然而第一个完全正确的二分查找算法实现直到1962年才出现。”</p><!-- [[[read_end]]] --><p>你可能会说，我们上一节学的二分查找的代码实现并不难写啊。那是因为上一节讲的只是二分查找中最简单的一种情况，在不存在重复元素的有序数组中，查找值等于给定值的元素。最简单的二分查找写起来确实不难，但是，二分查找的变形问题就没那么好写了。</p><p>二分查找的变形问题很多，我只选择几个典型的来讲解，其他的你可以借助我今天讲的思路自己来分析。</p><p><img src=\"https://static001.geekbang.org/resource/image/42/36/4221d02a2e88e9053085920f13f9ce36.jpg\" alt=\"\"></p><p>需要特别说明一点，为了简化讲解，今天的内容，我都以数据是从小到大排列为前提，如果你要处理的数据是从大到小排列的，解决思路也是一样的。同时，我希望你最好先自己动手试着写一下这4个变形问题，然后再看我的讲述，这样你就会对我说的“二分查找比较难写”有更加深的体会了。</p><h2>变体一：查找第一个值等于给定值的元素</h2><p>上一节中的二分查找是最简单的一种，即有序数据集合中不存在重复的数据，我们在其中查找值等于某个给定值的数据。如果我们将这个问题稍微修改下，有序数据集合中存在重复的数据，我们希望找到第一个值等于给定值的数据，这样之前的二分查找代码还能继续工作吗？</p><p>比如下面这样一个有序数组，其中，a[5]，a[6]，a[7]的值都等于8，是重复的数据。我们希望查找第一个等于8的数据，也就是下标是5的元素。</p><p><img src=\"https://static001.geekbang.org/resource/image/50/f8/503c572dd0f9d734b55f1bd12765c4f8.jpg\" alt=\"\"></p><p>如果我们用上一节课讲的二分查找的代码实现，首先拿8与区间的中间值a[4]比较，8比6大，于是在下标5到9之间继续查找。下标5和9的中间位置是下标7，a[7]正好等于8，所以代码就返回了。</p><p>尽管a[7]也等于8，但它并不是我们想要找的第一个等于8的元素，因为第一个值等于8的元素是数组下标为5的元素。我们上一节讲的二分查找代码就无法处理这种情况了。所以，针对这个变形问题，我们可以稍微改造一下上一节的代码。</p><p>100个人写二分查找就会有100种写法。网上有很多关于变形二分查找的实现方法，有很多写得非常简洁，比如下面这个写法。但是，尽管简洁，理解起来却非常烧脑，也很容易写错。</p><pre><code>public int bsearch(int[] a, int n, int value) {\n  int low = 0;\n  int high = n - 1;\n  while (low &lt;= high) {\n    int mid = low + ((high - low) &gt;&gt; 1);\n    if (a[mid] &gt;= value) {\n      high = mid - 1;\n    } else {\n      low = mid + 1;\n    }\n  }\n\n  if (low &lt; n &amp;&amp; a[low]==value) return low;\n  else return -1;\n}\n</code></pre><p>看完这个实现之后，你是不是觉得很不好理解？如果你只是死记硬背这个写法，我敢保证，过不了几天，你就会全都忘光，再让你写，90%的可能会写错。所以，我换了一种实现方法，你看看是不是更容易理解呢？</p><pre><code>public int bsearch(int[] a, int n, int value) {\n  int low = 0;\n  int high = n - 1;\n  while (low &lt;= high) {\n    int mid =  low + ((high - low) &gt;&gt; 1);\n    if (a[mid] &gt; value) {\n      high = mid - 1;\n    } else if (a[mid] &lt; value) {\n      low = mid + 1;\n    } else {\n      if ((mid == 0) || (a[mid - 1] != value)) return mid;\n      else high = mid - 1;\n    }\n  }\n  return -1;\n}\n</code></pre><p>我来稍微解释一下这段代码。a[mid]跟要查找的value的大小关系有三种情况：大于、小于、等于。对于a[mid]&gt;value的情况，我们需要更新high= mid-1；对于a[mid]&lt;value的情况，我们需要更新low=mid+1。这两点都很好理解。那当a[mid]=value的时候应该如何处理呢？</p><p>如果我们查找的是任意一个值等于给定值的元素，当a[mid]等于要查找的值时，a[mid]就是我们要找的元素。但是，如果我们求解的是第一个值等于给定值的元素，当a[mid]等于要查找的值时，我们就需要确认一下这个a[mid]是不是第一个值等于给定值的元素。</p><p>我们重点看第11行代码。如果mid等于0，那这个元素已经是数组的第一个元素，那它肯定是我们要找的；如果mid不等于0，但a[mid]的前一个元素a[mid-1]不等于value，那也说明a[mid]就是我们要找的第一个值等于给定值的元素。</p><p>如果经过检查之后发现a[mid]前面的一个元素a[mid-1]也等于value，那说明此时的a[mid]肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新high=mid-1，因为要找的元素肯定出现在[low, mid-1]之间。</p><p>对比上面的两段代码，是不是下面那种更好理解？实际上，<strong>很多人都觉得变形的二分查找很难写，主要原因是太追求第一种那样完美、简洁的写法</strong>。而对于我们做工程开发的人来说，代码易读懂、没Bug，其实更重要，所以我觉得第二种写法更好。</p><h2>变体二：查找最后一个值等于给定值的元素</h2><p>前面的问题是查找第一个值等于给定值的元素，我现在把问题稍微改一下，查找最后一个值等于给定值的元素，又该如何做呢？</p><p>如果你掌握了前面的写法，那这个问题你应该很轻松就能解决。你可以先试着实现一下，然后跟我写的对比一下。</p><pre><code>public int bsearch(int[] a, int n, int value) {\n  int low = 0;\n  int high = n - 1;\n  while (low &lt;= high) {\n    int mid =  low + ((high - low) &gt;&gt; 1);\n    if (a[mid] &gt; value) {\n      high = mid - 1;\n    } else if (a[mid] &lt; value) {\n      low = mid + 1;\n    } else {\n      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;\n      else low = mid + 1;\n    }\n  }\n  return -1;\n}\n</code></pre><p>我们还是重点看第11行代码。如果a[mid]这个元素已经是数组中的最后一个元素了，那它肯定是我们要找的；如果a[mid]的后一个元素a[mid+1]不等于value，那也说明a[mid]就是我们要找的最后一个值等于给定值的元素。</p><p>如果我们经过检查之后，发现a[mid]后面的一个元素a[mid+1]也等于value，那说明当前的这个a[mid]并不是最后一个值等于给定值的元素。我们就更新low=mid+1，因为要找的元素肯定出现在[mid+1, high]之间。</p><h2>变体三：查找第一个大于等于给定值的元素</h2><p>现在我们再来看另外一类变形问题。在有序数组中，查找第一个大于等于给定值的元素。比如，数组中存储的这样一个序列：3，4，6，7，10。如果查找第一个大于等于5的元素，那就是6。</p><p>实际上，实现的思路跟前面的那两种变形问题的实现思路类似，代码写起来甚至更简洁。</p><pre><code>public int bsearch(int[] a, int n, int value) {\n  int low = 0;\n  int high = n - 1;\n  while (low &lt;= high) {\n    int mid =  low + ((high - low) &gt;&gt; 1);\n    if (a[mid] &gt;= value) {\n      if ((mid == 0) || (a[mid - 1] &lt; value)) return mid;\n      else high = mid - 1;\n    } else {\n      low = mid + 1;\n    }\n  }\n  return -1;\n}\n</code></pre><p>如果a[mid]小于要查找的值value，那要查找的值肯定在[mid+1, high]之间，所以，我们更新low=mid+1。</p><p>对于a[mid]大于等于给定值value的情况，我们要先看下这个a[mid]是不是我们要找的第一个值大于等于给定值的元素。如果a[mid]前面已经没有元素，或者前面一个元素小于要查找的值value，那a[mid]就是我们要找的元素。这段逻辑对应的代码是第7行。</p><p>如果a[mid-1]也大于等于要查找的值value，那说明要查找的元素在[low, mid-1]之间，所以，我们将high更新为mid-1。</p><h2>变体四：查找最后一个小于等于给定值的元素</h2><p>现在，我们来看最后一种二分查找的变形问题，查找最后一个小于等于给定值的元素。比如，数组中存储了这样一组数据：3，5，6，8，9，10。最后一个小于等于7的元素就是6。是不是有点类似上面那一种？实际上，实现思路也是一样的。</p><p>有了前面的基础，你完全可以自己写出来了，所以我就不详细分析了。我把代码贴出来，你可以写完之后对比一下。</p><pre><code>public int bsearch7(int[] a, int n, int value) {\n  int low = 0;\n  int high = n - 1;\n  while (low &lt;= high) {\n    int mid =  low + ((high - low) &gt;&gt; 1);\n    if (a[mid] &gt; value) {\n      high = mid - 1;\n    } else {\n      if ((mid == n - 1) || (a[mid + 1] &gt; value)) return mid;\n      else low = mid + 1;\n    }\n  }\n  return -1;\n}\n</code></pre><h2>解答开篇</h2><p>好了，现在我们回头来看开篇的问题：如何快速定位出一个IP地址的归属地？</p><p>现在这个问题应该很简单了。如果IP区间与归属地的对应关系不经常更新，我们可以先预处理这12万条数据，让其按照起始IP从小到大排序。如何来排序呢？我们知道，IP地址可以转化为32位的整型数。所以，我们可以将起始地址，按照对应的整型值的大小关系，从小到大进行排序。</p><p>然后，这个问题就可以转化为我刚讲的第四种变形问题“在有序数组中，查找最后一个小于等于某个给定值的元素”了。</p><p>当我们要查询某个IP归属地时，我们可以先通过二分查找，找到最后一个起始IP小于等于这个IP的IP区间，然后，检查这个IP是否在这个IP区间内，如果在，我们就取出对应的归属地显示；如果不在，就返回未查找到。</p><h2>内容小结</h2><p>上一节我说过，凡是用二分查找能解决的，绝大部分我们更倾向于用散列表或者二叉查找树。即便是二分查找在内存使用上更节省，但是毕竟内存如此紧缺的情况并不多。那二分查找真的没什么用处了吗？</p><p>实际上，上一节讲的求“值等于给定值”的二分查找确实不怎么会被用到，二分查找更适合用在“近似”查找问题，在这类问题上，二分查找的优势更加明显。比如今天讲的这几种变体问题，用其他数据结构，比如散列表、二叉树，就比较难实现了。</p><p>变体的二分查找算法写起来非常烧脑，很容易因为细节处理不好而产生Bug，这些容易出错的细节有：<strong>终止条件、区间上下界更新方法、返回值选择</strong>。所以今天的内容你最好能用自己实现一遍，对锻炼编码能力、逻辑思维、写出Bug free代码，会很有帮助。</p><h2>课后思考</h2><p>我们今天讲的都是非常规的二分查找问题，今天的思考题也是一个非常规的二分查找问题。如果有序数组是一个循环有序数组，比如4，5，6，1，2，3。针对这种情况，如何实现一个求“值等于给定值”的二分查找算法呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"16 | 二分查找（下）：如何快速定位IP对应的省份地址？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/85/2e/85cda965e9b8a920d2ec3805f18da52e.mp3",
		"column_id":126,
		"id":42733,
		"site_source_url":"https://time.geekbang.org/column/article/42733"
	},
	{
		"article_content":"<p>今天我们讲一种针对有序数据集合的查找算法：二分查找（Binary Search）算法，也叫折半查找算法。二分查找的思想非常简单，很多非计算机专业的同学很容易就能理解，但是看似越简单的东西往往越难掌握好，想要灵活应用就更加困难。</p>\n<p>老规矩，我们还是来看一道思考题。</p>\n<p>假设我们有1000万个整数数据，每个数据占8个字节，<strong><span class=\"orange\">如何设计数据结构和算法，快速判断某个整数是否出现在这1000万数据中？</span></strong> 我们希望这个功能不要占用太多的内存空间，最多不要超过100MB，你会怎么做呢？带着这个问题，让我们进入今天的内容吧！</p>\n<h2>无处不在的二分思想</h2>\n<p>二分查找是一种非常简单易懂的快速查找算法，生活中到处可见。比如说，我们现在来做一个猜字游戏。我随机写一个0到99之间的数字，然后你来猜我写的是什么。猜的过程中，你每猜一次，我就会告诉你猜的大了还是小了，直到猜中为止。你来想想，如何快速猜中我写的数字呢？</p>\n<p>假设我写的数字是23，你可以按照下面的步骤来试一试。（如果猜测范围的数字有偶数个，中间数有两个，就选择较小的那个。）</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/9d/9b/9dadf04cdfa7b3724e0df91da7cacd9b.jpg\" alt=\"\" /></p>\n<p>7次就猜出来了，是不是很快？这个例子用的就是二分思想，按照这个思想，即便我让你猜的是0到999的数字，最多也只要10次就能猜中。不信的话，你可以试一试。</p><!-- [[[read_end]]] -->\n<p>这是一个生活中的例子，我们现在回到实际的开发场景中。假设有1000条订单数据，已经按照订单金额从小到大排序，每个订单金额都不同，并且最小单位是元。我们现在想知道是否存在金额等于19元的订单。如果存在，则返回订单数据，如果不存在则返回null。</p>\n<p>最简单的办法当然是从第一个订单开始，一个一个遍历这1000个订单，直到找到金额等于19元的订单为止。但这样查找会比较慢，最坏情况下，可能要遍历完这1000条记录才能找到。那用二分查找能不能更快速地解决呢？</p>\n<p>为了方便讲解，我们假设只有10个订单，订单金额分别是：8，11，19，23，27，33，45，55，67，98。</p>\n<p>还是利用二分思想，每次都与区间的中间数据比对大小，缩小查找区间的范围。为了更加直观，我画了一张查找过程的图。其中，low和high表示待查找区间的下标，mid表示待查找区间的中间元素下标。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8b/29/8bce81259abf0e9a06f115e22586b829.jpg\" alt=\"\" /></p>\n<p>看懂这两个例子，你现在对二分的思想应该掌握得妥妥的了。我这里稍微总结升华一下，<strong>二分查找针对的是一个有序的数据集合，查找思想有点类似分治思想。每次都通过跟区间的中间元素对比，将待查找的区间缩小为之前的一半，直到找到要查找的元素，或者区间被缩小为0</strong>。</p>\n<h2>O(logn)惊人的查找速度</h2>\n<p>二分查找是一种非常高效的查找算法，高效到什么程度呢？我们来分析一下它的时间复杂度。</p>\n<p>我们假设数据大小是n，每次查找后数据都会缩小为原来的一半，也就是会除以2。最坏情况下，直到查找区间被缩小为空，才停止。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/d1/94/d1e4fa1542e187184c87c545c2fe4794.jpg\" alt=\"\" /></p>\n<p>可以看出来，这是一个等比数列。其中n/2<sup>k</sup>=1时，k的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较，所以，经过了k次区间缩小操作，时间复杂度就是O(k)。通过n/2<sup>k</sup>=1，我们可以求得k=log<sub>2</sub>n，所以时间复杂度就是O(logn)。</p>\n<p>二分查找是我们目前为止遇到的第一个时间复杂度为O(logn)的算法。后面章节我们还会讲堆、二叉树的操作等等，它们的时间复杂度也是O(logn)。我这里就再深入地讲讲O(logn)这种<strong>对数时间复杂度</strong>。这是一种极其高效的时间复杂度，有的时候甚至比时间复杂度是常量级O(1)的算法还要高效。为什么这么说呢？</p>\n<p>因为logn是一个非常“恐怖”的数量级，即便n非常非常大，对应的logn也很小。比如n等于2的32次方，这个数很大了吧？大约是42亿。也就是说，如果我们在42亿个数据中用二分查找一个数据，最多需要比较32次。</p>\n<p>我们前面讲过，用大O标记法表示时间复杂度的时候，会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说，O(1)有可能表示的是一个非常大的常量值，比如O(1000)、O(10000)。所以，常量级时间复杂度的算法有时候可能还没有O(logn)的算法执行效率高。</p>\n<p>反过来，对数对应的就是指数。有一个非常著名的“阿基米德与国王下棋的故事”，你可以自行搜索一下，感受一下指数的“恐怖”。这也是为什么我们说，指数时间复杂度的算法在大规模数据面前是无效的。</p>\n<h2>二分查找的递归与非递归实现</h2>\n<p>实际上，简单的二分查找并不难写，注意我这里的“简单”二字。下一节，我们会讲到二分查找的变体问题，那才是真正烧脑的。今天，我们来看如何来写最简单的二分查找。</p>\n<p><strong>最简单的情况</strong>就是<strong>有序数组中不存在重复元素</strong>，我们在其中用二分查找值等于给定值的数据。我用Java代码实现了一个最简单的二分查找算法。</p>\n<pre><code>public int bsearch(int[] a, int n, int value) {\n  int low = 0;\n  int high = n - 1;\n\n  while (low &lt;= high) {\n    int mid = (low + high) / 2;\n    if (a[mid] == value) {\n      return mid;\n    } else if (a[mid] &lt; value) {\n      low = mid + 1;\n    } else {\n      high = mid - 1;\n    }\n  }\n\n  return -1;\n}\n</code></pre>\n<p>这个代码我稍微解释一下，low、high、mid都是指数组下标，其中low和high表示当前查找的区间范围，初始low=0， high=n-1。mid表示[low, high]的中间位置。我们通过对比a[mid]与value的大小，来更新接下来要查找的区间范围，直到找到或者区间缩小为0，就退出。如果你有一些编程基础，看懂这些应该不成问题。现在，我就着重强调一下<strong>容易出错的3个地方</strong>。</p>\n<h3>1.循环退出条件</h3>\n<p>注意是low&lt;=high，而不是low&lt;high。</p>\n<h3>2.mid的取值</h3>\n<p>实际上，mid=(low+high)/2这种写法是有问题的。因为如果low和high比较大的话，两者之和就有可能会溢出。改进的方法是将mid的计算方式写成low+(high-low)/2。更进一步，如果要将性能优化到极致的话，我们可以将这里的除以2操作转化成位运算low+((high-low)&gt;&gt;1)。因为相比除法运算来说，计算机处理位运算要快得多。</p>\n<h3>3.low和high的更新</h3>\n<p>low=mid+1，high=mid-1。注意这里的+1和-1，如果直接写成low=mid或者high=mid，就可能会发生死循环。比如，当high=3，low=3时，如果a[3]不等于value，就会导致一直循环不退出。</p>\n<p>如果你留意我刚讲的这三点，我想一个简单的二分查找你已经可以实现了。<strong>实际上，二分查找除了用循环来实现，还可以<strong><strong>用</strong></strong>递归来实现</strong>，过程也非常简单。</p>\n<p>我用Java语言实现了一下这个过程，正好你可以借此机会回顾一下写递归代码的技巧。</p>\n<pre><code>// 二分查找的递归实现\npublic int bsearch(int[] a, int n, int val) {\n  return bsearchInternally(a, 0, n - 1, val);\n}\n\nprivate int bsearchInternally(int[] a, int low, int high, int value) {\n  if (low &gt; high) return -1;\n\n  int mid =  low + ((high - low) &gt;&gt; 1);\n  if (a[mid] == value) {\n    return mid;\n  } else if (a[mid] &lt; value) {\n    return bsearchInternally(a, mid+1, high, value);\n  } else {\n    return bsearchInternally(a, low, mid-1, value);\n  }\n}\n</code></pre>\n<h2>二分查找应用场景的局限性</h2>\n<p>前面我们分析过，二分查找的时间复杂度是O(logn)，查找数据的效率非常高。不过，并不是什么情况下都可以用二分查找，它的应用场景是有很大局限性的。那什么情况下适合用二分查找，什么情况下不适合呢？</p>\n<p><strong>首先，二分查找依赖的是顺序表结构，简单点说就是数组。</strong></p>\n<p>那二分查找能否依赖其他数据结构呢？比如链表。答案是不可以的，主要原因是二分查找算法需要按照下标随机访问元素。我们在数组和链表那两节讲过，数组按照下标随机访问数据的时间复杂度是O(1)，而链表随机访问的时间复杂度是O(n)。所以，如果数据使用链表存储，二分查找的时间复杂就会变得很高。</p>\n<p>二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的，则无法应用二分查找。</p>\n<p><strong>其次，二分查找针对的是有序数据。</strong></p>\n<p>二分查找对这一点的要求比较苛刻，数据必须是有序的。如果数据没有序，我们需要先排序。前面章节里我们讲到，排序的时间复杂度最低是O(nlogn)。所以，如果我们针对的是一组静态的数据，没有频繁地插入、删除，我们可以进行一次排序，多次二分查找。这样排序的成本可被均摊，二分查找的边际成本就会比较低。</p>\n<p>但是，如果我们的数据集合有频繁的插入和删除操作，要想用二分查找，要么每次插入、删除操作之后保证数据仍然有序，要么在每次二分查找之前都先进行排序。针对这种动态数据集合，无论哪种方法，维护有序的成本都是很高的。</p>\n<p>所以，二分查找只能用在插入、删除操作不频繁，一次排序多次查找的场景中。针对动态变化的数据集合，二分查找将不再适用。那针对动态数据集合，如何在其中快速查找某个数据呢？别急，等到二叉树那一节我会详细讲。</p>\n<p><strong>再次，数据量太小不适合二分查找。</strong></p>\n<p>如果要处理的数据量很小，完全没有必要用二分查找，顺序遍历就足够了。比如我们在一个大小为10的数组中查找一个元素，不管用二分查找还是顺序遍历，查找速度都差不多。只有数据量比较大的时候，二分查找的优势才会比较明显。</p>\n<p>不过，这里有一个例外。如果数据之间的比较操作非常耗时，不管数据量大小，我都推荐使用二分查找。比如，数组中存储的都是长度超过300的字符串，如此长的两个字符串之间比对大小，就会非常耗时。我们需要尽可能地减少比较次数，而比较次数的减少会大大提高性能，这个时候二分查找就比顺序遍历更有优势。</p>\n<p><strong>最后，数据量太大也不适合二分查找。</strong></p>\n<p>二分查找的底层需要依赖数组这种数据结构，而数组为了支持随机访问的特性，要求内存空间连续，对内存的要求比较苛刻。比如，我们有1GB大小的数据，如果希望用数组来存储，那就需要1GB的连续内存空间。</p>\n<p>注意这里的“连续”二字，也就是说，即便有2GB的内存空间剩余，但是如果这剩余的2GB内存空间都是零散的，没有连续的1GB大小的内存空间，那照样无法申请一个1GB大小的数组。而我们的二分查找是作用在数组这种数据结构之上的，所以太大的数据用数组存储就比较吃力了，也就不能用二分查找了。</p>\n<h2>解答开篇</h2>\n<p>二分查找的理论知识你应该已经掌握了。我们来看下开篇的思考题：如何在1000万个整数中快速查找某个整数？</p>\n<p>这个问题并不难。我们的内存限制是100MB，每个数据大小是8字节，最简单的办法就是将数据存储在数组中，内存占用差不多是80MB，符合内存的限制。借助今天讲的内容，我们可以先对这1000万数据从小到大排序，然后再利用二分查找算法，就可以快速地查找想要的数据了。</p>\n<p>看起来这个问题并不难，很轻松就能解决。实际上，它暗藏了“玄机”。如果你对数据结构和算法有一定了解，知道散列表、二叉树这些支持快速查找的动态数据结构。你可能会觉得，用散列表和二叉树也可以解决这个问题。实际上是不行的。</p>\n<p>虽然大部分情况下，用二分查找可以解决的问题，用散列表、二叉树都可以解决。但是，我们后面会讲，不管是散列表还是二叉树，都会需要比较多的额外的内存空间。如果用散列表或者二叉树来存储这1000万的数据，用100MB的内存肯定是存不下的。而二分查找底层依赖的是数组，除了数据本身之外，不需要额外存储其他信息，是最省内存空间的存储方式，所以刚好能在限定的内存大小下解决这个问题。</p>\n<h2>内容小结</h2>\n<p>今天我们学习了一种针对有序数据的高效查找算法，二分查找，它的时间复杂度是O(logn)。</p>\n<p>二分查找的核心思想理解起来非常简单，有点类似分治思想。即每次都通过跟区间中的中间元素对比，将待查找的区间缩小为一半，直到找到要查找的元素，或者区间被缩小为0。但是二分查找的代码实现比较容易写错。你需要着重掌握它的三个容易出错的地方：循环退出条件、mid的取值，low和high的更新。</p>\n<p>二分查找虽然性能比较优秀，但应用场景也比较有限。底层必须依赖数组，并且还要求数据是有序的。对于较小规模的数据查找，我们直接使用顺序遍历就可以了，二分查找的优势并不明显。二分查找更适合处理静态数据，也就是没有频繁的数据插入、删除操作。</p>\n<h2>课后思考</h2>\n<ol>\n<li>\n<p>如何编程实现“求一个数的平方根”？要求精确到小数点后6位。</p>\n</li>\n<li>\n<p>我刚才说了，如果数据使用链表存储，二分查找的时间复杂就会变得很高，那查找的时间复杂度究竟是多少呢？如果你自己推导一下，你就会深刻地认识到，为何我们会选择用数组而不是链表来实现二分查找了。</p>\n</li>\n</ol>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"15 | 二分查找（上）：如何用最省内存的方式实现快速查找功能？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/ea/93/ea1cbc8c454b8eb12755d1ae5ff74693.mp3",
		"column_id":126,
		"id":42520,
		"site_source_url":"https://time.geekbang.org/column/article/42520"
	},
	{
		"article_content":"<p>几乎所有的编程语言都会提供排序函数，比如C语言中qsort()，C++ STL中的sort()、stable_sort()，还有Java语言中的Collections.sort()。在平时的开发中，我们也都是直接使用这些现成的函数来实现业务逻辑中的排序功能。那你知道这些排序函数是如何实现的吗？底层都利用了哪种排序算法呢？</p>\n<p>基于这些问题，今天我们就来看排序这部分的最后一块内容：<strong><span class=\"orange\">如何实现一个通用的、高性能的排序函数？</span></strong></p>\n<h2>如何选择合适的排序算法？</h2>\n<p>如果要实现一个通用的、高效率的排序函数，我们应该选择哪种排序算法？我们先回顾一下前面讲过的几种排序算法。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/1f/fd/1f6ef7e0a5365d6e9d68f0ccc71755fd.jpg\" alt=\"\" /></p>\n<p>我们前面讲过，线性排序算法的时间复杂度比较低，适用场景比较特殊。所以如果要写一个通用的排序函数，不能选择线性排序算法。</p>\n<p>如果对小规模数据进行排序，可以选择时间复杂度是O(n<sup>2</sup>)的算法；如果对大规模数据进行排序，时间复杂度是O(nlogn)的算法更加高效。所以，为了兼顾任意规模数据的排序，一般都会首选时间复杂度是O(nlogn)的排序算法来实现排序函数。</p>\n<p>时间复杂度是O(nlogn)的排序算法不止一个，我们已经讲过的有归并排序、快速排序，后面讲堆的时候我们还会讲到堆排序。堆排序和快速排序都有比较多的应用，比如Java语言采用堆排序实现排序函数，C语言使用快速排序实现排序函数。</p><!-- [[[read_end]]] -->\n<p>不知道你有没有发现，使用归并排序的情况其实并不多。我们知道，快排在最坏情况下的时间复杂度是O(n<sup>2</sup>)，而归并排序可以做到平均情况、最坏情况下的时间复杂度都是O(nlogn)，从这点上看起来很诱人，那为什么它还是没能得到“宠信”呢？</p>\n<p>还记得我们上一节讲的归并排序的空间复杂度吗？归并排序并不是原地排序算法，空间复杂度是O(n)。所以，粗略点、夸张点讲，如果要排序100MB的数据，除了数据本身占用的内存之外，排序算法还要额外再占用100MB的内存空间，空间耗费就翻倍了。</p>\n<p>前面我们讲到，快速排序比较适合来实现排序函数，但是，我们也知道，快速排序在最坏情况下的时间复杂度是O(n<sup>2</sup>)，如何来解决这个“复杂度恶化”的问题呢？</p>\n<h2>如何优化快速排序？</h2>\n<p>我们先来看下，为什么最坏情况下快速排序的时间复杂度是O(n<sup>2</sup>)呢？我们前面讲过，如果数据原来就是有序的或者接近有序的，每次分区点都选择最后一个数据，那快速排序算法就会变得非常糟糕，时间复杂度就会退化为O(n<sup>2</sup>)。实际上，<strong>这种O(n<sup>2</sup>)时间复杂度出现的主要原因还是因为我们分区点选的不够合理</strong>。</p>\n<p>那什么样的分区点是好的分区点呢？或者说如何来选择分区点呢？</p>\n<p>最理想的分区点是：<strong>被分区点分开的两个分区中，数据的数量差不多</strong>。</p>\n<p>如果很粗暴地直接选择第一个或者最后一个数据作为分区点，不考虑数据的特点，肯定会出现之前讲的那样，在某些情况下，排序的最坏情况时间复杂度是O(n<sup>2</sup>)。为了提高排序算法的性能，我们也要尽可能地让每次分区都比较平均。</p>\n<p>我这里介绍两个比较常用、比较简单的分区算法，你可以直观地感受一下。</p>\n<h3>1.三数取中法</h3>\n<p>我们从区间的首、尾、中间，分别取出一个数，然后对比大小，取这3个数的中间值作为分区点。这样每间隔某个固定的长度，取数据出来比较，将中间值作为分区点的分区算法，肯定要比单纯取某一个数据更好。但是，如果要排序的数组比较大，那“三数取中”可能就不够了，可能要“五数取中”或者“十数取中”。</p>\n<h3>2.随机法</h3>\n<p>随机法就是每次从要排序的区间中，随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好，但是从概率的角度来看，也不大可能会出现每次分区点都选的很差的情况，所以平均情况下，这样选的分区点是比较好的。时间复杂度退化为最糟糕的O(n<sup>2</sup>)的情况，出现的可能性不大。</p>\n<p>好了，我这里也只是抛砖引玉，如果想了解更多寻找分区点的方法，你可以自己课下深入去学习一下。</p>\n<p>我们知道，快速排序是用递归来实现的。我们在递归那一节讲过，递归要警惕堆栈溢出。为了避免快速排序里，递归过深而堆栈过小，导致堆栈溢出，我们有两种解决办法：第一种是限制递归深度。一旦递归过深，超过了我们事先设定的阈值，就停止递归。第二种是通过在堆上模拟实现一个函数调用栈，手动模拟递归压栈、出栈的过程，这样就没有了系统栈大小的限制。</p>\n<h2>举例分析排序函数</h2>\n<p>为了让你对如何实现一个排序函数有一个更直观的感受，我拿Glibc中的qsort()函数举例说明一下。虽说qsort()从名字上看，很像是基于快速排序算法实现的，实际上它并不仅仅用了快排这一种算法。</p>\n<p>如果你去看源码，你就会发现，<strong>qsort()会优先使用归并排序来排序输入数据</strong>，因为归并排序的空间复杂度是O(n)，所以对于小数据量的排序，比如1KB、2KB等，归并排序额外需要1KB、2KB的内存空间，这个问题不大。现在计算机的内存都挺大的，我们很多时候追求的是速度。还记得我们前面讲过的用空间换时间的技巧吗？这就是一个典型的应用。</p>\n<p>但如果数据量太大，就跟我们前面提到的，排序100MB的数据，这个时候我们再用归并排序就不合适了。所以，<strong>要排序的数据量比较大的时候，qsort()会改为用快速排序算法来排序</strong>。</p>\n<p>那qsort()是如何选择快速排序算法的分区点的呢？如果去看源码，你就会发现，qsort()选择分区点的方法就是“三数取中法”。是不是也并不复杂？</p>\n<p>还有我们前面提到的递归太深会导致堆栈溢出的问题，qsort()是通过自己实现一个堆上的栈，手动模拟递归来解决的。我们之前在讲递归那一节也讲过，不知道你还有没有印象？</p>\n<p>实际上，qsort()并不仅仅用到了归并排序和快速排序，它还用到了插入排序。在快速排序的过程中，当要排序的区间中，元素的个数小于等于4时，qsort()就退化为插入排序，不再继续用递归来做快速排序，因为我们前面也讲过，在小规模数据面前，<strong>O(n<sup>2</sup>)时间复杂度的算法并不一定比O(nlogn)的算法执行时间长</strong>。我们现在就来分析下这个说法。</p>\n<p>我们在讲复杂度分析的时候讲过，算法的性能可以通过时间复杂度来分析，但是，这种复杂度分析是比较偏理论的，如果我们深究的话，实际上时间复杂度并不等于代码实际的运行时间。</p>\n<p>时间复杂度代表的是一个增长趋势，如果画成增长曲线图，你会发现O(n<sup>2</sup>)比O(nlogn)要陡峭，也就是说增长趋势要更猛一些。但是，我们前面讲过，在大O复杂度表示法中，我们会省略低阶、系数和常数，也就是说，O(nlogn)在没有省略低阶、系数、常数之前可能是O(knlogn + c)，而且k和c有可能还是一个比较大的数。</p>\n<p>假设k=1000，c=200，当我们对小规模数据（比如n=100）排序时，n<sup>2</sup>的值实际上比knlogn+c还要小。</p>\n<pre><code>knlogn+c = 1000 * 100 * log100 + 200 远大于10000\n\nn^2 = 100*100 = 10000\n</code></pre>\n<p>所以，对于小规模数据的排序，O(n<sup>2</sup>)的排序算法并不一定比O(nlogn)排序算法执行的时间长。对于小数据量的排序，我们选择比较简单、不需要递归的插入排序算法。</p>\n<p>还记得我们之前讲到的哨兵来简化代码，提高执行效率吗？在qsort()插入排序的算法实现中，也利用了这种编程技巧。虽然哨兵可能只是少做一次判断，但是毕竟排序函数是非常常用、非常基础的函数，性能的优化要做到极致。</p>\n<p>好了，C语言的qsort()我已经分析完了，你有没有觉得其实也不是很难？基本上都是用了我们前面讲到的知识点，有了前面的知识积累，看一些底层的类库的时候是不是也更容易了呢？</p>\n<h2>内容小结</h2>\n<p>今天我带你分析了一下如何来实现一个工业级的通用的、高效的排序函数，内容比较偏实战，而且贯穿了一些前面几节的内容，你要多看几遍。我们大部分排序函数都是采用O(nlogn)排序算法来实现，但是为了尽可能地提高性能，会做很多优化。</p>\n<p>我还着重讲了快速排序的一些优化策略，比如合理选择分区点、避免递归太深等等。最后，我还带你分析了一个C语言中qsort()的底层实现原理，希望你对此能有一个更加直观的感受。</p>\n<h2>课后思考</h2>\n<p>在今天的内容中，我分析了C语言的中的qsort()的底层排序算法，你能否分析一下你所熟悉的语言中的排序函数都是用什么排序算法实现的呢？都有哪些优化技巧？</p>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<hr />\n<p><strong><span class=\"orange\">特别说明：</span></strong></p>\n<p>专栏已经更新一月有余，我在留言区看到很多同学说，希望给出课后思考题的标准答案。鉴于留言区里本身就有很多非常好的答案，之后我会将我认为比较好的答案置顶在留言区，供需要的同学参考。</p>\n<p>如果文章发布一周后，留言里依旧没有比较好的答案，我会把我的答案写出来置顶在留言区。</p>\n<p>最后，<strong>希望你把思考的过程看得比标准答案更重要。</strong></p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"14 | 排序优化：如何实现一个通用的、高性能的排序函数？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/14/2a/1419b92b418518b1ecd374af8a6a7d2a.mp3",
		"column_id":126,
		"id":42359,
		"site_source_url":"https://time.geekbang.org/column/article/42359"
	},
	{
		"article_content":"<p>上两节中，我带你着重分析了几种常用排序算法的原理、时间复杂度、空间复杂度、稳定性等。今天，我会讲三种时间复杂度是O(n)的排序算法：桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的，所以我们把这类排序算法叫作<strong>线性排序</strong>（Linear sort）。之所以能做到线性的时间复杂度，主要原因是，这三个算法是非基于比较的排序算法，都不涉及元素之间的比较操作。</p><p>这几种排序算法理解起来都不难，时间、空间复杂度分析起来也很简单，但是对要排序的数据要求很苛刻，所以我们<strong>今天学习重点的是掌握这些排序算法的适用场景</strong>。</p><p>按照惯例，我先给你出一道思考题：<strong><span class=\"orange\">如何根据年龄给100万用户排序？</span></strong> 你可能会说，我用上一节课讲的归并、快排就可以搞定啊！是的，它们也可以完成功能，但是时间复杂度最低也是O(nlogn)。有没有更快的排序方法呢？让我们一起进入今天的内容！</p><h2>桶排序（Bucket sort）</h2><p>首先，我们来看桶排序。桶排序，顾名思义，会用到“桶”，核心思想是将要排序的数据分到几个有序的桶里，每个桶里的数据再单独进行排序。桶内排完序之后，再把每个桶里的数据按照顺序依次取出，组成的序列就是有序的了。</p><p><img src=\"https://static001.geekbang.org/resource/image/98/ae/987564607b864255f81686829503abae.jpg\" alt=\"\"></p><p>桶排序的时间复杂度为什么是O(n)呢？我们一块儿来分析一下。</p><!-- [[[read_end]]] --><p>如果要排序的数据有n个，我们把它们均匀地划分到m个桶内，每个桶里就有k=n/m个元素。每个桶内部使用快速排序，时间复杂度为O(k * logk)。m个桶排序的时间复杂度就是O(m * k * logk)，因为k=n/m，所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时，log(n/m)就是一个非常小的常量，这个时候桶排序的时间复杂度接近O(n)。</p><p><strong>桶排序看起来很优秀，那它是不是可以替代我们之前讲的排序算法呢？</strong></p><p>答案当然是否定的。为了让你轻松理解桶排序的核心思想，我刚才做了很多假设。实际上，桶排序对要排序数据的要求是非常苛刻的。</p><p>首先，要排序的数据需要很容易就能划分成m个桶，并且，桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后，桶与桶之间的数据不需要再进行排序。</p><p>其次，数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后，有些桶里的数据非常多，有些非常少，很不平均，那桶内数据排序的时间复杂度就不是常量级了。在极端情况下，如果数据都被划分到一个桶里，那就退化为O(nlogn)的排序算法了。</p><p><strong>桶排序比较适合用在外部排序中</strong>。所谓的外部排序就是数据存储在外部磁盘中，数据量比较大，内存有限，无法将数据全部加载到内存中。</p><p>比如说我们有10GB的订单数据，我们希望按订单金额（假设金额都是正整数）进行排序，但是我们的内存有限，只有几百MB，没办法一次性把10GB的数据都加载到内存中。这个时候该怎么办呢？</p><p>现在我来讲一下，如何借助桶排序的处理思想来解决这个问题。</p><p>我们可以先扫描一遍文件，看订单金额所处的数据范围。假设经过扫描之后我们得到，订单金额最小是1元，最大是10万元。我们将所有订单根据金额划分到100个桶里，第一个桶我们存储金额在1元到1000元之内的订单，第二桶存储金额在1001元到2000元之内的订单，以此类推。每一个桶对应一个文件，并且按照金额范围的大小顺序编号命名（00，01，02…99）。</p><p>理想的情况下，如果订单金额在1到10万之间均匀分布，那订单会被均匀划分到100个文件中，每个小文件中存储大约100MB的订单数据，我们就可以将这100个小文件依次放到内存中，用快排来排序。等所有文件都排好序之后，我们只需要按照文件编号，从小到大依次读取每个小文件中的订单数据，并将其写入到一个文件中，那这个文件中存储的就是按照金额从小到大排序的订单数据了。</p><p>不过，你可能也发现了，订单按照金额在1元到10万元之间并不一定是均匀分布的 ，所以10GB订单数据是无法均匀地被划分到100个文件中的。有可能某个金额区间的数据特别多，划分之后对应的文件就会很大，没法一次性读入内存。这又该怎么办呢？</p><p>针对这些划分之后还是比较大的文件，我们可以继续划分，比如，订单金额在1元到1000元之间的比较多，我们就将这个区间继续划分为10个小区间，1元到100元，101元到200元，201元到300元…901元到1000元。如果划分之后，101元到200元之间的订单还是太多，无法一次性读入内存，那就继续再划分，直到所有的文件都能读入内存为止。</p><h2>计数排序（Counting sort）</h2><p>我个人觉得，<strong>计数排序其实是桶排序的一种特殊情况</strong>。当要排序的n个数据，所处的范围并不大的时候，比如最大值是k，我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的，省掉了桶内排序的时间。</p><p>我们都经历过高考，高考查分数系统你还记得吗？我们查分数的时候，系统会显示我们的成绩以及所在省的排名。如果你所在的省有50万考生，如何通过成绩快速排序得出名次呢？</p><p>考生的满分是900分，最小是0分，这个数据的范围很小，所以我们可以分成901个桶，对应分数从0分到900分。根据考生的成绩，我们将这50万考生划分到这901个桶里。桶内的数据都是分数相同的考生，所以并不需要再进行排序。我们只需要依次扫描每个桶，将桶内的考生依次输出到一个数组中，就实现了50万考生的排序。因为只涉及扫描遍历操作，所以时间复杂度是O(n)。</p><p>计数排序的算法思想就是这么简单，跟桶排序非常类似，只是桶的大小粒度不一样。<strong>不过，为什么这个排序算法叫“计数”排序呢？“计数”的含义来自哪里呢？</strong></p><p>想弄明白这个问题，我们就要来看计数排序算法的实现方法。我还拿考生那个例子来解释。为了方便说明，我对数据规模做了简化。假设只有8个考生，分数在0到5分之间。这8个考生的成绩我们放在一个数组A[8]中，它们分别是：2，5，3，0，2，3，0，3。</p><p>考生的成绩从0到5分，我们使用大小为6的数组C[6]表示桶，其中下标对应分数。不过，C[6]内存储的并不是考生，而是对应的考生个数。像我刚刚举的那个例子，我们只需要遍历一遍考生分数，就可以得到C[6]的值。</p><p><img src=\"https://static001.geekbang.org/resource/image/ad/c9/adc75672ef33fa54b023a040834fcbc9.jpg\" alt=\"\"></p><p>从图中可以看出，分数为3分的考生有3个，小于3分的考生有4个，所以，成绩为3分的考生在排序之后的有序数组R[8]中，会保存下标4，5，6的位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/36/29/361f4d781d2a2d144dcbbbb0b9e6db29.jpg\" alt=\"\"></p><p>那我们如何快速计算出，每个分数的考生在有序数组中对应的存储位置呢？这个处理方法非常巧妙，很不容易想到。</p><p>思路是这样的：我们对C[6]数组顺序求和，C[6]存储的数据就变成了下面这样子。C[k]里存储小于等于分数k的考生个数。</p><p><img src=\"https://static001.geekbang.org/resource/image/dd/1f/dd6c62b12b0dc1b3a294af0fa1ce371f.jpg\" alt=\"\"></p><p>有了前面的数据准备之后，现在我就要讲计数排序中最复杂、最难理解的一部分了，请集中精力跟着我的思路！</p><p>我们从后到前依次扫描数组A。比如，当扫描到3时，我们可以从数组C中取出下标为3的值7，也就是说，到目前为止，包括自己在内，分数小于等于3的考生有7个，也就是说3是数组R中的第7个元素（也就是数组R中下标为6的位置）。当3放入到数组R中后，小于等于3的元素就只剩下了6个了，所以相应的C[3]要减1，变成6。</p><p>以此类推，当我们扫描到第2个分数为3的考生的时候，就会把它放入数组R中的第6个元素的位置（也就是下标为5的位置）。当我们扫描完整个数组A后，数组R内的数据就是按照分数从小到大有序排列的了。</p><p><img src=\"https://static001.geekbang.org/resource/image/1d/84/1d730cb17249f8e92ef5cab53ae65784.jpg\" alt=\"\"></p><p>上面的过程有点复杂，我写成了代码，你可以对照着看下。</p><pre><code>// 计数排序，a是数组，n是数组大小。假设数组中存储的都是非负整数。\npublic void countingSort(int[] a, int n) {\n  if (n &lt;= 1) return;\n\n  // 查找数组中数据的范围\n  int max = a[0];\n  for (int i = 1; i &lt; n; ++i) {\n    if (max &lt; a[i]) {\n      max = a[i];\n    }\n  }\n\n  int[] c = new int[max + 1]; // 申请一个计数数组c，下标大小[0,max]\n  for (int i = 0; i &lt;= max; ++i) {\n    c[i] = 0;\n  }\n\n  // 计算每个元素的个数，放入c中\n  for (int i = 0; i &lt; n; ++i) {\n    c[a[i]]++;\n  }\n\n  // 依次累加\n  for (int i = 1; i &lt;= max; ++i) {\n    c[i] = c[i-1] + c[i];\n  }\n\n  // 临时数组r，存储排序之后的结果\n  int[] r = new int[n];\n  // 计算排序的关键步骤，有点难理解\n  for (int i = n - 1; i &gt;= 0; --i) {\n    int index = c[a[i]]-1;\n    r[index] = a[i];\n    c[a[i]]--;\n  }\n\n  // 将结果拷贝给a数组\n  for (int i = 0; i &lt; n; ++i) {\n    a[i] = r[i];\n  }\n}\n</code></pre><p>这种利用另外一个数组来计数的实现方式是不是很巧妙呢？这也是为什么这种排序算法叫计数排序的原因。不过，你千万不要死记硬背上面的排序过程，重要的是理解和会用。</p><p>我总结一下，<strong>计数排序只能用在数据范围不大的场景<strong><strong>中</strong></strong>，如果数据范围k比要排序的数据n大很多，就<strong><strong>不</strong></strong>适合用<strong><strong>计数</strong></strong>排序了。而且，<strong><strong>计数</strong></strong>排序只能<strong><strong>给</strong></strong>非负整数<strong><strong>排序</strong></strong>，如果要排序的数据是其他类型的，要<strong><strong>将</strong></strong>其在不改变相对大小的情况下，转化为非负整数。</strong></p><p>比如，还是拿考生这个例子。如果考生成绩精确到小数后一位，我们就需要将所有的分数都先乘以10，转化成整数，然后再放到9010个桶内。再比如，如果要排序的数据中有负数，数据的范围是[-1000, 1000]，那我们就需要先对每个数据都加1000，转化成非负整数。</p><h2>基数排序（Radix sort）</h2><p>我们再来看这样一个排序问题。假设我们有10万个手机号码，希望将这10万个手机号码从小到大排序，你有什么比较快速的排序方法呢？</p><p>我们之前讲的快排，时间复杂度可以做到O(nlogn)，还有更高效的排序算法吗？桶排序、计数排序能派上用场吗？手机号码有11位，范围太大，显然不适合用这两种排序算法。针对这个排序问题，有没有时间复杂度是O(n)的算法呢？现在我就来介绍一种新的排序算法，基数排序。</p><p>刚刚这个问题里有这样的规律：假设要比较两个手机号码a，b的大小，如果在前面几位中，a手机号码已经比b手机号码大了，那后面的几位就不用看了。</p><p>借助稳定排序算法，这里有一个巧妙的实现思路。还记得我们第11节中，在阐述排序算法的稳定性的时候举的订单的例子吗？我们这里也可以借助相同的处理思路，先按照最后一位来排序手机号码，然后，再按照倒数第二位重新排序，以此类推，最后按照第一位重新排序。经过11次排序之后，手机号码就都有序了。</p><p>手机号码稍微有点长，画图比较不容易看清楚，我用字符串排序的例子，画了一张基数排序的过程分解图，你可以看下。</p><p><img src=\"https://static001.geekbang.org/resource/image/df/0c/df0cdbb73bd19a2d69a52c54d8b9fc0c.jpg\" alt=\"\"></p><p>注意，这里按照每位来排序的排序算法要是稳定的，否则这个实现思路就是不正确的。因为如果是非稳定排序算法，那最后一次排序只会考虑最高位的大小顺序，完全不管其他位的大小关系，那么低位的排序就完全没有意义了。</p><p>根据每一位来排序，我们可以用刚讲过的桶排序或者计数排序，它们的时间复杂度可以做到O(n)。如果要排序的数据有k位，那我们就需要k次桶排序或者计数排序，总的时间复杂度是O(k*n)。当k不大的时候，比如手机号码排序的例子，k最大就是11，所以基数排序的时间复杂度就近似于O(n)。</p><p>实际上，有时候要排序的数据并不都是等长的，比如我们排序牛津字典中的20万个英文单词，最短的只有1个字母，最长的我特意去查了下，有45个字母，中文翻译是尘肺病。对于这种不等长的数据，基数排序还适用吗？</p><p>实际上，<strong>我们可以把所有的单词补齐到相同长度，位数不够的可以在后面补“0”</strong>，因为根据<a href=\"https://zh.wiktionary.org/wiki/US-ASCII\">ASCII值</a>，所有字母都大于“0”，所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。</p><p>我来总结一下，<strong>基数排序对要排序的数据是有要求的，需要可以分割出独立的“位”来比较，而且位之间有递进的关系，如果a数据的高位比b数据大，那剩下的低位就不用比较了。除此之外，每一位的数据范围不能太大，要可以用线性排序算法来排序，否则，基数排序的时间复杂度就无法做到O(n)了</strong>。</p><h2>解答开篇</h2><p>今天的内容学完了。我们再回过头来看看开篇的思考题：如何根据年龄给100万用户排序？现在思考题是不是变得非常简单了呢？我来说一下我的解决思路。</p><p>实际上，根据年龄给100万用户排序，就类似按照成绩给50万考生排序。我们假设年龄的范围最小1岁，最大不超过120岁。我们可以遍历这100万用户，根据年龄将其划分到这120个桶里，然后依次顺序遍历这120个桶中的元素。这样就得到了按照年龄排序的100万用户数据。</p><h2>内容小结</h2><p>今天，我们学习了3种线性时间复杂度的排序算法，有桶排序、计数排序、基数排序。它们对要排序的数据都有比较苛刻的要求，应用不是非常广泛。但是如果数据特征比较符合这些排序算法的要求，应用这些算法，会非常高效，线性时间复杂度可以达到O(n)。</p><p>桶排序和计数排序的排序思想是非常相似的，都是针对范围不大的数据，将数据划分成不同的桶来实现排序。基数排序要求数据可以划分成高低位，位之间有递进关系。比较两个数，我们只需要比较高位，高位相同的再比较低位。而且每一位的数据范围不能太大，因为基数排序算法需要借助桶排序或者计数排序来完成每一个位的排序工作。</p><h2>课后思考</h2><p>我们今天讲的都是针对特殊数据的排序算法。实际上，还有很多看似是排序但又不需要使用排序算法就能处理的排序问题。</p><p>假设我们现在需要对D，a，F，B，c，A，z这个字符串进行排序，要求将其中所有小写字母都排在大写字母的前面，但小写字母内部和大写字母内部不要求有序。比如经过排序之后为a，c，z，D，F，B，A，这个如何来实现呢？如果字符串中存储的不仅有大小写字母，还有数字。要将小写字母的放到前面，大写字母放在最后，数字放在中间，不用排序算法，又该怎么解决呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"13 | 线性排序：如何根据年龄给100万用户数据排序？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/87/02/870534b4030cd547698d00534c27ae02.mp3",
		"column_id":126,
		"id":42038,
		"site_source_url":"https://time.geekbang.org/column/article/42038"
	},
	{
		"article_content":"<p>上一节我讲了冒泡排序、插入排序、选择排序这三种排序算法，它们的时间复杂度都是O(n<sup>2</sup>)，比较高，适合小规模数据的排序。今天，我讲两种时间复杂度为O(nlogn)的排序算法，<strong>归并排序</strong>和<strong>快速排序</strong>。这两种排序算法适合大规模的数据排序，比上一节讲的那三种排序算法要更常用。</p><p>归并排序和快速排序都用到了分治思想，非常巧妙。我们可以借鉴这个思想，来解决非排序的问题，比如：<strong><span class=\"orange\">如何在O(n)的时间复杂度内查找一个无序数组中的第K大元素？</span></strong> 这就要用到我们今天要讲的内容。</p><h2>归并排序的原理</h2><p>我们先来看<strong>归并排序</strong>（Merge Sort）。</p><p>归并排序的核心思想还是蛮简单的。如果要排序一个数组，我们先把数组从中间分成前后两部分，然后对前后两部分分别排序，再将排好序的两部分合并在一起，这样整个数组就都有序了。</p><p><img src=\"https://static001.geekbang.org/resource/image/db/2b/db7f892d3355ef74da9cd64aa926dc2b.jpg\" alt=\"\"></p><p>归并排序使用的就是<strong>分治思想</strong>。分治，顾名思义，就是分而治之，将一个大问题分解成小的子问题来解决。小的子问题解决了，大问题也就解决了。</p><p>从我刚才的描述，你有没有感觉到，分治思想跟我们前面讲的递归思想很像。是的，分治算法一般都是用递归来实现的。<strong>分治是一种解决问题的处理思想，递归是一种编程技巧</strong>，这两者并不冲突。分治算法的思想我后面会有专门的一节来讲，现在不展开讨论，我们今天的重点还是排序算法。</p><!-- [[[read_end]]] --><p>前面我通过举例让你对归并有了一个感性的认识，又告诉你，归并排序用的是分治思想，可以用递归来实现。我们现在就来看看<strong>如何用递归代码来实现归并排序</strong>。</p><p>我在<a href=\"https://time.geekbang.org/column/article/41440\">第10节</a>讲的递归代码的编写技巧你还记得吗？写递归代码的技巧就是，分析得出递推公式，然后找到终止条件，最后将递推公式翻译成递归代码。所以，要想写出归并排序的代码，我们先写出归并排序的递推公式。</p><pre><code>递推公式：\nmerge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))\n\n终止条件：\np &gt;= r 不用再继续分解\n</code></pre><p>我来解释一下这个递推公式。</p><p>merge_sort(p…r)表示，给下标从p到r之间的数组排序。我们将这个排序问题转化为了两个子问题，merge_sort(p…q)和merge_sort(q+1…r)，其中下标q等于p和r的中间位置，也就是(p+r)/2。当下标从p到q和从q+1到r这两个子数组都排好序之后，我们再将两个有序的子数组合并在一起，这样下标从p到r之间的数据就也排好序了。</p><p>有了递推公式，转化成代码就简单多了。为了阅读方便，我这里只给出伪代码，你可以翻译成你熟悉的编程语言。</p><pre><code>// 归并排序算法, A是数组，n表示数组大小\nmerge_sort(A, n) {\n  merge_sort_c(A, 0, n-1)\n}\n\n// 递归调用函数\nmerge_sort_c(A, p, r) {\n  // 递归终止条件\n  if p &gt;= r  then return\n\n  // 取p到r之间的中间位置q\n  q = (p+r) / 2\n  // 分治递归\n  merge_sort_c(A, p, q)\n  merge_sort_c(A, q+1, r)\n  // 将A[p...q]和A[q+1...r]合并为A[p...r]\n  merge(A[p...r], A[p...q], A[q+1...r])\n}\n</code></pre><p>你可能已经发现了，merge(A[p…r], A[p…q], A[q+1…r])这个函数的作用就是，将已经有序的A[p…q]和A[q+1…r]合并成一个有序的数组，并且放入A[p…r]。那这个过程具体该如何做呢？</p><p>如图所示，我们申请一个临时数组tmp，大小与A[p…r]相同。我们用两个游标i和j，分别指向A[p…q]和A[q+1…r]的第一个元素。比较这两个元素A[i]和A[j]，如果A[i]&lt;=A[j]，我们就把A[i]放入到临时数组tmp，并且i后移一位，否则将A[j]放入到数组tmp，j后移一位。</p><p>继续上述比较过程，直到其中一个子数组中的所有数据都放入临时数组中，再把另一个数组中的数据依次加入到临时数组的末尾，这个时候，临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到原数组A[p…r]中。</p><p><img src=\"https://static001.geekbang.org/resource/image/95/2f/95897ade4f7ad5d10af057b1d144a22f.jpg\" alt=\"\"></p><p>我们把merge()函数写成伪代码，就是下面这样：</p><pre><code>merge(A[p...r], A[p...q], A[q+1...r]) {\n  var i := p，j := q+1，k := 0 // 初始化变量i, j, k\n  var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组\n  while i&lt;=q AND j&lt;=r do {\n    if A[i] &lt;= A[j] {\n      tmp[k++] = A[i++] // i++等于i:=i+1\n    } else {\n      tmp[k++] = A[j++]\n    }\n  }\n  \n  // 判断哪个子数组中有剩余的数据\n  var start := i，end := q\n  if j&lt;=r then start := j, end:=r\n  \n  // 将剩余的数据拷贝到临时数组tmp\n  while start &lt;= end do {\n    tmp[k++] = A[start++]\n  }\n  \n  // 将tmp中的数组拷贝回A[p...r]\n  for i:=0 to r-p do {\n    A[p+i] = tmp[i]\n  }\n}\n</code></pre><p>你还记得<a href=\"https://time.geekbang.org/column/article/41149\">第7讲</a>讲过的利用哨兵简化编程的处理技巧吗？merge()合并函数如果借助哨兵，代码就会简洁很多，这个问题留给你思考。</p><h2>归并排序的性能分析</h2><p>这样跟着我一步一步分析，归并排序是不是没那么难啦？还记得上节课我们分析排序算法的三个问题吗？接下来，我们来看归并排序的三个问题。</p><p><strong>第一，归并排序是<strong><strong>稳定</strong></strong>的排序算法吗？</strong></p><p>结合我前面画的那张图和归并排序的伪代码，你应该能发现，归并排序稳不稳定关键要看merge()函数，也就是两个有序子数组合并成一个有序数组的那部分代码。</p><p>在合并的过程中，如果A[p…q]和A[q+1…r]之间有值相同的元素，那我们可以像伪代码中那样，先把A[p…q]中的元素放入tmp数组。这样就保证了值相同的元素，在合并前后的先后顺序不变。所以，归并排序是一个稳定的排序算法。</p><p><strong>第二，归并排序的<strong><strong>时间复杂度</strong></strong>是多少？</strong></p><p>归并排序涉及递归，时间复杂度的分析稍微有点复杂。我们正好借此机会来学习一下，如何分析递归代码的时间复杂度。</p><p>在递归那一节我们讲过，递归的适用场景是，一个问题a可以分解为多个子问题b、c，那求解问题a就可以分解为求解问题b、c。问题b、c解决之后，我们再把b、c的结果合并成a的结果。</p><p>如果我们定义求解问题a的时间是T(a)，求解问题b、c的时间分别是T(b)和 T( c)，那我们就可以得到这样的递推关系式：</p><pre><code>T(a) = T(b) + T(c) + K\n</code></pre><p>其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。</p><p>从刚刚的分析，我们可以得到一个重要的结论：<strong>不仅递归求解的问题可以写成递推公式，递归代码的时间复杂度也可以写成递推公式。</strong></p><p>套用这个公式，我们来分析一下归并排序的时间复杂度。</p><p>我们假设对n个元素进行归并排序需要的时间是T(n)，那分解成两个子数组排序的时间都是T(n/2)。我们知道，merge()函数合并两个有序子数组的时间复杂度是O(n)。所以，套用前面的公式，归并排序的时间复杂度的计算公式就是：</p><pre><code>T(1) = C；   n=1时，只需要常量级的执行时间，所以表示为C。\nT(n) = 2*T(n/2) + n； n&gt;1\n</code></pre><p>通过这个公式，如何来求解T(n)呢？还不够直观？那我们再进一步分解一下计算过程。</p><pre><code>T(n) = 2*T(n/2) + n\n     = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n\n     = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n\n     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n\n     ......\n     = 2^k * T(n/2^k) + k * n\n     ......\n</code></pre><p>通过这样一步一步分解推导，我们可以得到T(n) = 2^k<em>T(n/2^k)+k</em>n。当T(n/2^k)=T(1)时，也就是n/2^k=1，我们得到k=log<sub>2</sub>n 。我们将k值代入上面的公式，得到T(n)=C<em>n+n</em>log<sub>2</sub>n 。如果我们用大O标记法来表示的话，T(n)就等于O(nlogn)。所以归并排序的时间复杂度是O(nlogn)。</p><p>从我们的原理分析和伪代码可以看出，归并排序的执行效率与要排序的原始数组的有序程度无关，所以其时间复杂度是非常稳定的，不管是最好情况、最坏情况，还是平均情况，时间复杂度都是O(nlogn)。</p><p><strong>第三，归并排序的<strong><strong>空间复杂度</strong></strong>是多少？</strong></p><p>归并排序的时间复杂度任何情况下都是O(nlogn)，看起来非常优秀。（待会儿你会发现，即便是快速排序，最坏情况下，时间复杂度也是O(n<sup>2</sup>)。）但是，归并排序并没有像快排那样，应用广泛，这是为什么呢？因为它有一个致命的“弱点”，那就是归并排序不是原地排序算法。</p><p>这是因为归并排序的合并函数，在合并两个有序数组为一个有序数组时，需要借助额外的存储空间。这一点你应该很容易理解。那我现在问你，归并排序的空间复杂度到底是多少呢？是O(n)，还是O(nlogn)，应该如何分析呢？</p><p>如果我们继续按照分析递归时间复杂度的方法，通过递推公式来求解，那整个归并过程需要的空间复杂度就是O(nlogn)。不过，类似分析时间复杂度那样来分析空间复杂度，这个思路对吗？</p><p>实际上，递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点，那就是，尽管每次合并操作都需要申请额外的内存空间，但在合并完成之后，临时开辟的内存空间就被释放掉了。在任意时刻，CPU只会有一个函数在执行，也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小，所以空间复杂度是O(n)。</p><h2>快速排序的原理</h2><p>我们再来看快速排序算法（Quicksort），我们习惯性把它简称为“快排”。快排利用的也是分治思想。乍看起来，它有点像归并排序，但是思路其实完全不一样。我们待会会讲两者的区别。现在，我们先来看下快排的核心思想。</p><p>快排的思想是这样的：如果要排序数组中下标从p到r之间的一组数据，我们选择p到r之间的任意一个数据作为pivot（分区点）。</p><p>我们遍历p到r之间的数据，将小于pivot的放到左边，将大于pivot的放到右边，将pivot放到中间。经过这一步骤之后，数组p到r之间的数据就被分成了三个部分，前面p到q-1之间都是小于pivot的，中间是pivot，后面的q+1到r之间是大于pivot的。</p><p><img src=\"https://static001.geekbang.org/resource/image/4d/81/4d892c3a2e08a17f16097d07ea088a81.jpg\" alt=\"\"></p><p>根据分治、递归的处理思想，我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据，直到区间缩小为1，就说明所有的数据都有序了。</p><p>如果我们用递推公式来将上面的过程写出来的话，就是这样：</p><pre><code>递推公式：\nquick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)\n\n终止条件：\np &gt;= r\n</code></pre><p>我将递推公式转化成递归代码。跟归并排序一样，我还是用伪代码来实现，你可以翻译成你熟悉的任何语言。</p><pre><code>// 快速排序，A是数组，n表示数组的大小\nquick_sort(A, n) {\n  quick_sort_c(A, 0, n-1)\n}\n// 快速排序递归函数，p,r为下标\nquick_sort_c(A, p, r) {\n  if p &gt;= r then return\n  \n  q = partition(A, p, r) // 获取分区点\n  quick_sort_c(A, p, q-1)\n  quick_sort_c(A, q+1, r)\n}\n</code></pre><p>归并排序中有一个merge()合并函数，我们这里有一个partition()分区函数。partition()分区函数实际上我们前面已经讲过了，就是随机选择一个元素作为pivot（一般情况下，可以选择p到r区间的最后一个元素），然后对A[p…r]分区，函数返回pivot的下标。</p><p>如果我们不考虑空间消耗的话，partition()分区函数可以写得非常简单。我们申请两个临时数组X和Y，遍历A[p…r]，将小于pivot的元素都拷贝到临时数组X，将大于pivot的元素都拷贝到临时数组Y，最后再将数组X和数组Y中数据顺序拷贝到A[p…r]。</p><p><img src=\"https://static001.geekbang.org/resource/image/66/dc/6643bc3cef766f5b3e4526c332c60adc.jpg\" alt=\"\"></p><p>但是，如果按照这种思路实现的话，partition()函数就需要很多额外的内存空间，所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法，那它的空间复杂度得是O(1)，那partition()分区函数就不能占用太多额外的内存空间，我们就需要在A[p…r]的原地完成分区操作。</p><p>原地分区函数的实现思路非常巧妙，我写成了伪代码，我们一起来看一下。</p><pre><code>partition(A, p, r) {\n  pivot := A[r]\n  i := p\n  for j := p to r-1 do {\n    if A[j] &lt; pivot {\n      swap A[i] with A[j]\n      i := i+1\n    }\n  }\n  swap A[i] with A[r]\n  return i\n\n</code></pre><p>这里的处理有点类似选择排序。我们通过游标i把A[p…r-1]分成两部分。A[p…i-1]的元素都是小于pivot的，我们暂且叫它“已处理区间”，A[i…r-1]是“未处理区间”。我们每次都从未处理的区间A[i…r-1]中取一个元素A[j]，与pivot对比，如果小于pivot，则将其加入到已处理区间的尾部，也就是A[i]的位置。</p><p>数组的插入操作还记得吗？在数组某个位置插入元素，需要搬移数据，非常耗时。当时我们也讲了一种处理技巧，就是交换，在O(1)的时间复杂度内完成插入操作。这里我们也借助这个思想，只需要将A[i]与A[j]交换，就可以在O(1)时间复杂度内将A[j]放到下标为i的位置。</p><p>文字不如图直观，所以我画了一张图来展示分区的整个过程。</p><p><img src=\"https://static001.geekbang.org/resource/image/08/e7/086002d67995e4769473b3f50dd96de7.jpg\" alt=\"\"></p><p>因为分区的过程涉及交换操作，如果数组中有两个相同的元素，比如序列6，8，7，6，3，5，9，4，在经过第一次分区操作之后，两个6的相对先后顺序就会改变。所以，快速排序并不是一个稳定的排序算法。</p><p>到此，快速排序的原理你应该也掌握了。现在，我再来看另外一个问题：快排和归并用的都是分治思想，递推公式和递归代码也非常相似，那它们的区别在哪里呢？</p><p><img src=\"https://static001.geekbang.org/resource/image/aa/05/aa03ae570dace416127c9ccf9db8ac05.jpg\" alt=\"\"></p><p>可以发现，归并排序的处理过程是<strong>由下到上</strong>的，先处理子问题，然后再合并。而快排正好相反，它的处理过程是<strong>由上到下</strong>的，先分区，然后再处理子问题。归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法，但是它是非原地排序算法。我们前面讲过，归并之所以是非原地排序算法，主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数，可以实现原地排序，解决了归并排序占用太多内存的问题。</p><h2>快速排序的性能分析</h2><p>现在，我们来分析一下快速排序的性能。我在讲解快排的实现原理的时候，已经分析了稳定性和空间复杂度。快排是一种原地、不稳定的排序算法。现在，我们集中精力来看快排的时间复杂度。</p><p>快排也是用递归来实现的。对于递归代码的时间复杂度，我前面总结的公式，这里也还是适用的。如果每次分区操作，都能正好把数组分成大小接近相等的两个小区间，那快排的时间复杂度递推求解公式跟归并是相同的。所以，快排的时间复杂度也是O(nlogn)。</p><pre><code>T(1) = C；   n=1时，只需要常量级的执行时间，所以表示为C。\nT(n) = 2*T(n/2) + n； n&gt;1\n</code></pre><p>但是，公式成立的前提是每次分区操作，我们选择的pivot都很合适，正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。</p><p>我举一个比较极端的例子。如果数组中的数据原来已经是有序的了，比如1，3，5，6，8。如果我们每次选择最后一个元素作为pivot，那每次分区得到的两个区间都是不均等的。我们需要进行大约n次分区操作，才能完成快排的整个过程。每次分区我们平均要扫描大约n/2个元素，这种情况下，快排的时间复杂度就从O(nlogn)退化成了O(n<sup>2</sup>)。</p><p>我们刚刚讲了两个极端情况下的时间复杂度，一个是分区极其均衡，一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均情况时间复杂度是多少呢？</p><p>我们假设每次分区操作都将区间分成大小为9:1的两个小区间。我们继续套用递归时间复杂度的递推公式，就会变成这样：</p><pre><code>T(1) = C；   n=1时，只需要常量级的执行时间，所以表示为C。\n\nT(n) = T(n/10) + T(9*n/10) + n； n&gt;1\n</code></pre><p>这个公式的递推求解的过程非常复杂，虽然可以求解，但我不推荐用这种方法。实际上，递归的时间复杂度的求解方法除了递推公式之外，还有递归树，在树那一节我再讲，这里暂时不说。我这里直接给你结论：T(n)在大部分情况下的时间复杂度都可以做到O(nlogn)，只有在极端情况下，才会退化到O(n<sup>2</sup>)。而且，我们也有很多方法将这个概率降到很低，如何来做？我们后面章节再讲。</p><h2>解答开篇</h2><p>快排核心思想就是<strong>分治</strong>和<strong>分区</strong>，我们可以利用分区的思想，来解答开篇的问题：O(n)时间复杂度内求无序数组中的第K大元素。比如，4， 2， 5， 12， 3这样一组数据，第3大元素就是4。</p><p>我们选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot，对数组A[0…n-1]原地分区，这样数组就分成了三部分，A[0…p-1]、A[p]、A[p+1…n-1]。</p><p>如果p+1=K，那A[p]就是要求解的元素；如果K&gt;p+1, 说明第K大元素出现在A[p+1…n-1]区间，我们再按照上面的思路递归地在A[p+1…n-1]这个区间内查找。同理，如果K&lt;p+1，那我们就在A[0…p-1]区间查找。</p><p><img src=\"https://static001.geekbang.org/resource/image/89/91/898d94fc32e0a795fd65897293b98791.jpg\" alt=\"\"></p><p>我们再来看，为什么上述解决思路的时间复杂度是O(n)？</p><p>第一次分区查找，我们需要对大小为n的数组执行分区操作，需要遍历n个元素。第二次分区查找，我们只需要对大小为n/2的数组执行分区操作，需要遍历n/2个元素。依次类推，分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为1。</p><p>如果我们把每次分区遍历的元素个数加起来，就是：n+n/2+n/4+n/8+…+1。这是一个等比数列求和，最后的和等于2n-1。所以，上述解决思路的时间复杂度就为O(n)。</p><p>你可能会说，我有个很笨的办法，每次取数组中的最小值，将其移动到数组的最前面，然后在剩下的数组中继续找最小值，以此类推，执行K次，找到的数据不就是第K大元素了吗？</p><p>不过，时间复杂度就并不是O(n)了，而是O(K * n)。你可能会说，时间复杂度前面的系数不是可以忽略吗？O(K * n)不就等于O(n)吗？</p><p>这个可不能这么简单地划等号。当K是比较小的常量时，比如1、2，那最好时间复杂度确实是O(n)；但当K等于n/2或者n时，这种最坏情况下的时间复杂度就是O(n<sup>2</sup>)了。</p><h2>内容小结</h2><p>归并排序和快速排序是两种稍微复杂的排序算法，它们用的都是分治的思想，代码都通过递归来实现，过程非常相似。理解归并排序的重点是理解递推公式和merge()合并函数。同理，理解快排的重点也是理解递推公式，还有partition()分区函数。</p><p>归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法，这也使它存在致命的缺点，即归并排序不是原地排序算法，空间复杂度比较高，是O(n)。正因为此，它也没有快排应用广泛。</p><p>快速排序算法虽然最坏情况下的时间复杂度是O(n<sup>2</sup>)，但是平均情况下时间复杂度都是O(nlogn)。不仅如此，快速排序算法时间复杂度退化到O(n<sup>2</sup>)的概率非常小，我们可以通过合理地选择pivot来避免这种情况。</p><h2>课后思考</h2><p>现在你有10个接口访问日志文件，每个日志文件大小约300MB，每个文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件，合并为1个日志文件，合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有1GB，你有什么好的解决思路，能“快速”地将这10个日志文件合并吗？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"12 | 排序（下）：如何用快排思想在O(n)内查找第K大元素？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/a3/c9/a369281c659b210ae00292bb6f5682c9.mp3",
		"column_id":126,
		"id":41913,
		"site_source_url":"https://time.geekbang.org/column/article/41913"
	},
	{
		"article_content":"<p>排序对于任何一个程序员来说，可能都不会陌生。你学的第一个算法，可能就是排序。大部分编程语言中，也都提供了排序函数。在平常的项目中，我们也经常会用到排序。排序非常重要，所以我会花多一点时间来详细讲一讲经典的排序算法。</p><p>排序算法太多了，有很多可能你连名字都没听说过，比如猴子排序、睡眠排序、面条排序等。我只讲众多排序算法中的一小撮，也是最经典的、最常用的：冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。我按照时间复杂度把它们分成了三类，分三节课来讲解。</p><p><img src=\"https://static001.geekbang.org/resource/image/fb/cd/fb8394a588b12ff6695cfd664afb17cd.jpg\" alt=\"\"></p><p>带着问题去学习，是最有效的学习方法。所以按照惯例，我还是先给你出一个思考题：<strong><span class=\"orange\">插入排序和冒泡排序的时间复杂度相同，都是O(n<sup>2</sup>)，在实际的软件开发里，为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢？</span></strong></p><p>你可以先思考一两分钟，带着这个问题，我们开始今天的内容！</p><h2>如何分析一个“排序算法”？</h2><p>学习排序算法，我们除了学习它的算法原理、代码实现之外，更重要的是要学会如何评价、分析一个排序算法。那分析一个排序算法，要从哪几个方面入手呢？</p><h3><span class=\"orange\">排序算法的执行效率</span></h3><p>对于排序算法执行效率的分析，我们一般会从这几个方面来衡量：</p><p><strong>1.最好情况、最坏情况、平均<strong><strong>情况</strong></strong>时间复杂度</strong></p><!-- [[[read_end]]] --><p>我们在分析排序算法的时间复杂度时，要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外，你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。</p><p>为什么要区分这三种时间复杂度呢？第一，有些排序算法会区分，为了好对比，所以我们最好都做一下区分。第二，对于要排序的数据，有的接近有序，有的完全无序。有序度不同的数据，对于排序的执行时间肯定是有影响的，我们要知道排序算法在不同数据下的性能表现。</p><p><strong>2.时间复杂度的系数、常数 、低阶</strong></p><p>我们知道，时间复杂度反应的是数据规模n很大的时候的一个增长趋势，所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中，我们排序的可能是10个、100个、1000个这样规模很小的数据，所以，在对同一阶时间复杂度的排序算法性能对比的时候，我们就要把系数、常数、低阶也考虑进来。</p><p><strong>3.比较次数和交换（或移动）次数</strong></p><p>这一节和下一节讲的都是基于比较的排序算法。基于比较的排序算法的执行过程，会涉及两种操作，一种是元素比较大小，另一种是元素交换或移动。所以，如果我们在分析排序算法的执行效率的时候，应该把比较次数和交换（或移动）次数也考虑进去。</p><h3><span class=\"orange\">排序算法的内存消耗</span></h3><p>我们前面讲过，算法的内存消耗可以通过空间复杂度来衡量，排序算法也不例外。不过，针对排序算法的空间复杂度，我们还引入了一个新的概念，<strong>原地排序</strong>（Sorted in place）。原地排序算法，就是特指空间复杂度是O(1)的排序算法。我们今天讲的三种排序算法，都是原地排序算法。</p><h3><span class=\"orange\">排序算法的稳定性</span></h3><p>仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法，我们还有一个重要的度量指标，<strong>稳定性</strong>。这个概念是说，如果待排序的序列中存在值相等的元素，经过排序之后，相等元素之间原有的先后顺序不变。</p><p>我通过一个例子来解释一下。比如我们有一组数据2，9，3，4，8，3，按照大小排序之后就是2，3，3，4，8，9。</p><p>这组数据里有两个3。经过某种排序算法排序之后，如果两个3的前后顺序没有改变，那我们就把这种排序算法叫作<strong>稳定的排序算法</strong>；如果前后顺序发生变化，那对应的排序算法就叫作<strong>不稳定的排序算法</strong>。</p><p>你可能要问了，两个3哪个在前，哪个在后有什么关系啊，稳不稳定又有什么关系呢？为什么要考察排序算法的稳定性呢？</p><p>很多数据结构和算法课程，在讲排序的时候，都是用整数来举例，但在真正软件开发中，我们要排序的往往不是单纯的整数，而是一组对象，我们需要按照对象的某个key来排序。</p><p>比如说，我们现在要给电商交易系统中的“订单”排序。订单有两个属性，一个是下单时间，另一个是订单金额。如果我们现在有10万条订单数据，我们希望按照金额从小到大对订单数据排序。对于金额相同的订单，我们希望按照下单时间从早到晚有序。对于这样一个排序需求，我们怎么来做呢？</p><p>最先想到的方法是：我们先按照金额对订单数据进行排序，然后，再遍历排序之后的订单数据，对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难，但是实现起来会很复杂。</p><p>借助稳定排序算法，这个问题可以非常简洁地解决。解决思路是这样的：我们先按照下单时间给订单排序，注意是按照下单时间，不是金额。排序完成之后，我们用稳定排序算法，按照订单金额重新排序。两遍排序之后，我们得到的订单数据就是按照金额从小到大排序，金额相同的订单按照下单时间从早到晚排序的。为什么呢？</p><p><strong>稳定排序算法可以保持金额相同的两个对象，在排序之后的前后顺序不变</strong>。第一次排序之后，所有的订单按照下单时间从早到晚有序了。在第二次排序中，我们用的是稳定的排序算法，所以经过第二次排序之后，相同金额的订单仍然保持下单时间从早到晚有序。</p><p><img src=\"https://static001.geekbang.org/resource/image/13/59/1381c1f3f7819ae61ab17455ed7f0b59.jpg\" alt=\"\"></p><h2>冒泡排序（Bubble Sort）</h2><p>我们从冒泡排序开始，学习今天的三种排序算法。</p><p>冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较，看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置，重复n次，就完成了n个数据的排序工作。</p><p>我用一个例子，带你看下冒泡排序的整个过程。我们要对一组数据4，5，6，3，2，1，从小到到大进行排序。第一次冒泡操作的详细过程就是这样：</p><p><img src=\"https://static001.geekbang.org/resource/image/40/e9/4038f64f47975ab9f519e4f739e464e9.jpg\" alt=\"\"></p><p>可以看出，经过一次冒泡操作之后，6这个元素已经存储在正确的位置上。要想完成所有数据的排序，我们只要进行6次这样的冒泡操作就行了。</p><p><img src=\"https://static001.geekbang.org/resource/image/92/09/9246f12cca22e5d872cbfce302ef4d09.jpg\" alt=\"\"></p><p>实际上，刚讲的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时，说明已经达到完全有序，不用再继续执行后续的冒泡操作。我这里还有另外一个例子，这里面给6个元素排序，只需要4次冒泡操作就可以了。</p><p><img src=\"https://static001.geekbang.org/resource/image/a9/e6/a9783a3b13c11a5e064c5306c261e8e6.jpg\" alt=\"\"></p><p>冒泡排序算法的原理比较容易理解，具体的代码我贴到下面，你可以结合着代码来看我前面讲的原理。</p><pre><code>// 冒泡排序，a表示数组，n表示数组大小\npublic void bubbleSort(int[] a, int n) {\n  if (n &lt;= 1) return;\n \n for (int i = 0; i &lt; n; ++i) {\n    // 提前退出冒泡循环的标志位\n    boolean flag = false;\n    for (int j = 0; j &lt; n - i - 1; ++j) {\n      if (a[j] &gt; a[j+1]) { // 交换\n        int tmp = a[j];\n        a[j] = a[j+1];\n        a[j+1] = tmp;\n        flag = true;  // 表示有数据交换      \n      }\n    }\n    if (!flag) break;  // 没有数据交换，提前退出\n  }\n}\n</code></pre><p>现在，结合刚才我分析排序算法的三个方面，我有三个问题要问你。</p><p><strong>第一，冒泡排序是原地排序算法吗？</strong></p><p>冒泡的过程只涉及相邻数据的交换操作，只需要常量级的临时空间，所以它的空间复杂度为O(1)，是一个原地排序算法。</p><p><strong>第二，冒泡排序是稳定的排序算法吗？</strong></p><p>在冒泡排序中，只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性，当有相邻的两个元素大小相等的时候，我们不做交换，相同大小的数据在排序前后不会改变顺序，所以冒泡排序是稳定的排序算法。</p><p><strong>第三，冒泡排序<strong><strong>的时间复杂度</strong></strong>是多少？</strong></p><p>最好情况下，要排序的数据已经是有序的了，我们只需要进行一次冒泡操作，就可以结束了，所以最好情况时间复杂度是O(n)。而最坏的情况是，要排序的数据刚好是倒序排列的，我们需要进行n次冒泡操作，所以最坏情况时间复杂度为O(n<sup>2</sup>)。</p><p><img src=\"https://static001.geekbang.org/resource/image/fe/0f/fe107c06da8b290fb78fcce4f6774c0f.jpg\" alt=\"\"></p><p>最好、最坏情况下的时间复杂度很容易分析，那平均情况下的时间复杂是多少呢？我们前面讲过，平均时间复杂度就是加权平均期望时间复杂度，分析的时候要结合概率论的知识。</p><p>对于包含n个数据的数组，这n个数据就有n!种排列方式。不同的排列方式，冒泡排序执行的时间肯定是不同的。比如我们前面举的那两个例子，其中一个要进行6次冒泡，而另一个只需要4次。如果用概率论方法定量分析平均时间复杂度，涉及的数学推理和计算就会很复杂。我这里还有一种思路，通过“<strong>有序度</strong>”和“<strong>逆序度</strong>”这两个概念来进行分析。</p><p><strong>有序度</strong>是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样：</p><pre><code>有序元素对：a[i] &lt;= a[j], 如果i &lt; j。\n</code></pre><p><img src=\"https://static001.geekbang.org/resource/image/a1/20/a1ef4cc1999d6bd0af08d8417ee55220.jpg\" alt=\"\"></p><p>同理，对于一个倒序排列的数组，比如6，5，4，3，2，1，有序度是0；对于一个完全有序的数组，比如1，2，3，4，5，6，有序度就是<strong>n*(n-1)/2</strong>，也就是15。我们把这种完全有序的数组的有序度叫作<strong>满有序度</strong>。</p><p>逆序度的定义正好跟有序度相反（默认从小到大为有序），我想你应该已经想到了。关于逆序度，我就不举例子讲了。你可以对照我讲的有序度的例子自己看下。</p><pre><code>逆序元素对：a[i] &gt; a[j], 如果i &lt; j。\n</code></pre><p>关于这三个概念，我们还可以得到一个公式：<strong>逆序度=满有序度-有序度</strong>。我们排序的过程就是一种增加有序度，减少逆序度的过程，最后达到满有序度，就说明排序完成了。</p><p>我还是拿前面举的那个冒泡排序的例子来说明。要排序的数组的初始状态是4，5，6，3，2，1 ，其中，有序元素对有(4，5) (4，6)(5，6)，所以有序度是3。n=6，所以排序完成之后终态的满有序度为n*(n-1)/2=15。</p><p><img src=\"https://static001.geekbang.org/resource/image/88/34/8890cbf63ea80455ce82490a23361134.jpg\" alt=\"\"></p><p>冒泡排序包含两个操作原子，<strong>比较</strong>和<strong>交换</strong>。每交换一次，有序度就加1。不管算法怎么改进，交换次数总是确定的，即为<strong>逆序度，<strong>也就是</strong>n*(n-1)/2–初始有序度</strong>。此例中就是15–3=12，要进行12次交换操作。</p><p>对于包含n个数据的数组进行冒泡排序，平均交换次数是多少呢？最坏情况下，初始状态的有序度是0，所以要进行n*(n-1)/2次交换。最好情况下，初始状态的有序度是n*(n-1)/2，就不需要进行交换。我们可以取个中间值n*(n-1)/4，来表示初始有序度既不是很高也不是很低的平均情况。</p><p>换句话说，平均情况下，需要n*(n-1)/4次交换操作，比较操作肯定要比交换操作多，而复杂度的上限是O(n<sup>2</sup>)，所以平均情况下的时间复杂度就是O(n<sup>2</sup>)。</p><p>这个平均时间复杂度推导过程其实并不严格，但是很多时候很实用，毕竟概率论的定量分析太复杂，不太好用。等我们讲到快排的时候，我还会再次用这种“不严格”的方法来分析平均时间复杂度。</p><h2>插入排序（Insertion Sort）</h2><p>我们先来看一个问题。一个有序的数组，我们往里面添加一个新的数据后，如何继续保持数据有序呢？很简单，我们只要遍历数组，找到数据应该插入的位置将其插入即可。</p><p><img src=\"https://static001.geekbang.org/resource/image/7b/a6/7b257e179787c633d2bd171a764171a6.jpg\" alt=\"\"></p><p>这是一个动态排序的过程，即动态地往有序集合中添加数据，我们可以通过这种方法保持集合中的数据一直有序。而对于一组静态数据，我们也可以借鉴上面讲的插入方法，来进行排序，于是就有了插入排序算法。</p><p>那<strong>插入排序具体是如何借助上面的思想来实现排序的呢</strong>？</p><p>首先，我们将数组中的数据分为两个区间，<strong>已排序区间</strong>和<strong>未排序区间</strong>。初始已排序区间只有一个元素，就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素，在已排序区间中找到合适的插入位置将其插入，并保证已排序区间数据一直有序。重复这个过程，直到未排序区间中元素为空，算法结束。</p><p>如图所示，要排序的数据是4，5，6，1，3，2，其中左侧为已排序区间，右侧是未排序区间。</p><p><img src=\"https://static001.geekbang.org/resource/image/b6/e1/b60f61ec487358ac037bf2b6974d2de1.jpg\" alt=\"\"></p><p>插入排序也包含两种操作，一种是<strong>元素的比较</strong>，一种是<strong>元素<strong><strong>的</strong></strong>移动</strong>。当我们需要将一个数据a插入到已排序区间时，需要拿a与已排序区间的元素依次比较大小，找到合适的插入位置。找到插入点之后，我们还需要将插入点之后的元素顺序往后移动一位，这样才能腾出位置给元素a插入。</p><p>对于不同的查找插入点方法（从头到尾、从尾到头），元素的比较次数是有区别的。但对于一个给定的初始序列，移动操作的次数总是固定的，就等于逆序度。</p><p>为什么说移动次数就等于逆序度呢？我拿刚才的例子画了一个图表，你一看就明白了。满有序度是n*(n-1)/2=15，初始序列的有序度是5，所以逆序度是10。插入排序中，数据移动的个数总和也等于10=3+3+4。</p><p><img src=\"https://static001.geekbang.org/resource/image/fd/01/fd6582d5e5927173ee35d7cc74d9c401.jpg\" alt=\"\"></p><p>插入排序的原理也很简单吧？我也将代码实现贴在这里，你可以结合着代码再看下。</p><pre><code>// 插入排序，a表示数组，n表示数组大小\npublic void insertionSort(int[] a, int n) {\n  if (n &lt;= 1) return;\n\n  for (int i = 1; i &lt; n; ++i) {\n    int value = a[i];\n    int j = i - 1;\n    // 查找插入的位置\n    for (; j &gt;= 0; --j) {\n      if (a[j] &gt; value) {\n        a[j+1] = a[j];  // 数据移动\n      } else {\n        break;\n      }\n    }\n    a[j+1] = value; // 插入数据\n  }\n}\n</code></pre><p>现在，我们来看点稍微复杂的东西。我这里还是有三个问题要问你。</p><p><strong>第一，插入排序是原地排序算法吗？</strong></p><p>从实现过程可以很明显地看出，插入排序算法的运行并不需要额外的存储空间，所以空间复杂度是O(1)，也就是说，这是一个原地排序算法。</p><p><strong>第二，插入排序是<strong><strong>稳定</strong></strong>的排序算法吗？</strong></p><p>在插入排序中，对于值相同的元素，我们可以选择将后面出现的元素，插入到前面出现元素的后面，这样就可以保持原有的前后顺序不变，所以插入排序是稳定的排序算法。</p><p><strong>第三，插入排序<strong><strong>的时间复杂度</strong></strong>是多少？</strong></p><p>如果要排序的数据已经是有序的，我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置，每次只需要比较一个数据就能确定插入的位置。所以这种情况下，最好是时间复杂度为O(n)。注意，这里是<strong>从尾到头遍历已经有序的数据</strong>。</p><p>如果数组是倒序的，每次插入都相当于在数组的第一个位置插入新的数据，所以需要移动大量的数据，所以最坏情况时间复杂度为O(n<sup>2</sup>)。</p><p>还记得我们在数组中插入一个数据的平均时间复杂度是多少吗？没错，是O(n)。所以，对于插入排序来说，每次插入操作都相当于在数组中插入一个数据，循环执行n次插入操作，所以平均时间复杂度为O(n<sup>2</sup>)。</p><h2>选择排序（Selection Sort）</h2><p>选择排序算法的实现思路有点类似插入排序，也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素，将其放到已排序区间的末尾。</p><p><img src=\"https://static001.geekbang.org/resource/image/32/1d/32371475a0b08f0db9861d102474181d.jpg\" alt=\"\"></p><p>照例，也有三个问题需要你思考，不过前面两种排序算法我已经分析得很详细了，这里就直接公布答案了。</p><p>首先，选择排序空间复杂度为O(1)，是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n<sup>2</sup>)。你可以自己来分析看看。</p><p>那选择排序是稳定的排序算法吗？这个问题我着重来说一下。</p><p>答案是否定的，选择排序是一种不稳定的排序算法。从我前面画的那张图中，你可以看出来，选择排序每次都要找剩余未排序元素中的最小值，并和前面的元素交换位置，这样破坏了稳定性。</p><p>比如5，8，5，2，9这样一组数据，使用选择排序算法来排序的话，第一次找到最小元素2，与第一个5交换位置，那第一个5和中间的5顺序就变了，所以就不稳定了。正是因此，相对于冒泡排序和插入排序，选择排序就稍微逊色了。</p><h2>解答开篇</h2><p>基本的知识都讲完了，我们来看开篇的问题：冒泡排序和插入排序的时间复杂度都是O(n<sup>2</sup>)，都是原地排序算法，为什么插入排序要比冒泡排序更受欢迎呢？</p><p>我们前面分析冒泡排序和插入排序的时候讲到，冒泡排序不管怎么优化，元素交换的次数是一个固定值，是原始数据的逆序度。插入排序是同样的，不管怎么优化，元素移动的次数也等于原始数据的逆序度。</p><p>但是，从代码实现上来看，冒泡排序的数据交换要比插入排序的数据移动要复杂，冒泡排序需要3个赋值操作，而插入排序只需要1个。我们来看这段操作：</p><pre><code>冒泡排序中数据的交换操作：\nif (a[j] &gt; a[j+1]) { // 交换\n   int tmp = a[j];\n   a[j] = a[j+1];\n   a[j+1] = tmp;\n   flag = true;\n}\n\n插入排序中数据的移动操作：\nif (a[j] &gt; value) {\n  a[j+1] = a[j];  // 数据移动\n} else {\n  break;\n}\n</code></pre><p>我们把执行一个赋值语句的时间粗略地计为单位时间（unit_time），然后分别用冒泡排序和插入排序对同一个逆序度是K的数组进行排序。用冒泡排序，需要K次交换操作，每次需要3个赋值语句，所以交换操作总耗时就是3*K单位时间。而插入排序中数据移动操作只需要K个单位时间。</p><p>这个只是我们非常理论的分析，为了实验，针对上面的冒泡排序和插入排序的Java代码，我写了一个性能对比测试程序，随机生成10000个数组，每个数组中包含200个数据，然后在我的机器上分别用冒泡和插入排序算法来排序，冒泡排序算法大约700ms才能执行完成，而插入排序只需要100ms左右就能搞定！</p><p>所以，虽然冒泡排序和插入排序在时间复杂度上是一样的，都是O(n<sup>2</sup>)，但是如果我们希望把性能优化做到极致，那肯定首选插入排序。插入排序的算法思路也有很大的优化空间，我们只是讲了最基础的一种。如果你对插入排序的优化感兴趣，可以自行学习一下<a href=\"https://zh.wikipedia.org/wiki/%E5%B8%8C%E5%B0%94%E6%8E%92%E5%BA%8F\">希尔排序</a>。</p><h2>内容小结</h2><p>要想分析、评价一个排序算法，需要从执行效率、内存消耗和稳定性三个方面来看。因此，这一节，我带你分析了三种时间复杂度是O(n<sup>2</sup>)的排序算法，冒泡排序、插入排序、选择排序。你需要重点掌握的是它们的分析方法。</p><p><img src=\"https://static001.geekbang.org/resource/image/34/50/348604caaf0a1b1d7fee0512822f0e50.jpg\" alt=\"\"></p><p>这三种时间复杂度为O(n<sup>2</sup>)的排序算法中，冒泡排序、选择排序，可能就纯粹停留在理论的层面了，学习的目的也只是为了开拓思维，实际开发中应用并不多，但是插入排序还是挺有用的。后面讲排序优化的时候，我会讲到，有些编程语言中的排序函数的实现原理会用到插入排序算法。</p><p>今天讲的这三种排序算法，实现代码都非常简单，对于小规模数据的排序，用起来非常高效。但是在大规模数据排序的时候，这个时间复杂度还是稍微有点高，所以我们更倾向于用下一节要讲的时间复杂度为O(nlogn)的排序算法。</p><h2>课后思考</h2><p>我们讲过，特定算法是依赖特定的数据结构的。我们今天讲的几种排序算法，都是基于数组实现的。如果数据存储在链表中，这三种排序算法还能工作吗？如果能，那相应的时间、空间复杂度又是多少呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"11 | 排序（上）：为什么插入排序比冒泡排序更受欢迎？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/56/b9/56928cef28986117385a455e82bd10b9.mp3",
		"column_id":126,
		"id":41802,
		"site_source_url":"https://time.geekbang.org/column/article/41802"
	},
	{
		"article_content":"<p>推荐注册返佣金的这个功能我想你应该不陌生吧？现在很多App都有这个功能。这个功能中，用户A推荐用户B来注册，用户B又推荐了用户C来注册。我们可以说，用户C的“最终推荐人”为用户A，用户B的“最终推荐人”也为用户A，而用户A没有“最终推荐人”。</p>\n<p>一般来说，我们会通过数据库来记录这种推荐关系。在数据库表中，我们可以记录两行数据，其中actor_id表示用户id，referrer_id表示推荐人id。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/29/0e/2984d45578440e9a348144c70d124a0e.jpg\" alt=\"\" /></p>\n<p>基于这个背景，我的问题是，<strong><span class=\"orange\">给定一个用户ID，如何查找这个用户的“最终推荐人”？</span></strong> 带着这个问题，我们来学习今天的内容，递归（Recursion）！</p>\n<h2>如何理解“递归”？</h2>\n<p>从我自己学习数据结构和算法的经历来看，我个人觉得，有两个最难理解的知识点，一个是<strong>动态规划</strong>，另一个就是<strong>递归</strong>。</p>\n<p>递归是一种应用非常广泛的算法（或者编程技巧）。之后我们要讲的很多数据结构和算法的编码实现都要用到递归，比如DFS深度优先搜索、前中后序二叉树遍历等等。所以，搞懂递归非常重要，否则，后面复杂一些的数据结构和算法学起来就会比较吃力。</p>\n<p>不过，别看我说了这么多，递归本身可是一点儿都不“高冷”，咱们生活中就有很多用到递归的例子。</p>\n<p>周末你带着女朋友去电影院看电影，女朋友问你，咱们现在坐在第几排啊？电影院里面太黑了，看不清，没法数，现在你怎么办？</p><!-- [[[read_end]]] -->\n<p>别忘了你是程序员，这个可难不倒你，递归就开始排上用场了。于是你就问前面一排的人他是第几排，你想只要在他的数字上加一，就知道自己在哪一排了。但是，前面的人也看不清啊，所以他也问他前面的人。就这样一排一排往前问，直到问到第一排的人，说我在第一排，然后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排，于是你就知道答案了。</p>\n<p>这就是一个非常标准的递归求解问题的分解过程，去的过程叫“递”，回来的过程叫“归”。基本上，所有的递归问题都可以用递推公式来表示。刚刚这个生活中的例子，我们用递推公式将它表示出来就是这样的：</p>\n<pre><code>f(n)=f(n-1)+1 其中，f(1)=1\n</code></pre>\n<p>f(n)表示你想知道自己在哪一排，f(n-1)表示前面一排所在的排数，f(1)=1表示第一排的人知道自己在第一排。有了这个递推公式，我们就可以很轻松地将它改为递归代码，如下：</p>\n<pre><code>int f(int n) {\n  if (n == 1) return 1;\n  return f(n-1) + 1;\n}\n</code></pre>\n<h2>递归需要满足的三个条件</h2>\n<p>刚刚这个例子是非常典型的递归，那究竟什么样的问题可以用递归来解决呢？我总结了三个条件，只要同时满足以下三个条件，就可以用递归来解决。</p>\n<p><strong>1.一个问题的解可以分解为几个子问题的解</strong></p>\n<p>何为子问题？子问题就是数据规模更小的问题。比如，前面讲的电影院的例子，你要知道，“自己在哪一排”的问题，可以分解为“前一排的人在哪一排”这样一个子问题。</p>\n<p><strong>2.这个问题与分解之后的子问题，除了数据规模不同，求解思路完全一样</strong></p>\n<p>比如电影院那个例子，你求解“自己在哪一排”的思路，和前面一排人求解“自己在哪一排”的思路，是一模一样的。</p>\n<p><strong>3.存在递归终止条件</strong></p>\n<p>把问题分解为子问题，把子问题再分解为子子问题，一层一层分解下去，不能存在无限循环，这就需要有终止条件。</p>\n<p>还是电影院的例子，第一排的人不需要再继续询问任何人，就知道自己在哪一排，也就是f(1)=1，这就是递归的终止条件。</p>\n<h2>如何编写递归代码？</h2>\n<p>刚刚铺垫了这么多，现在我们来看，如何来写递归代码？我个人觉得，写递归代码最关键的是<strong>写出递推公式，找到终止条件</strong>，剩下将递推公式转化为代码就很简单了。</p>\n<p>你先记住这个理论。我举一个例子，带你一步一步实现一个递归代码，帮你理解。</p>\n<p>假如这里有n个台阶，每次你可以跨1个台阶或者2个台阶，请问走这n个台阶有多少种走法？如果有7个台阶，你可以2，2，2，1这样子上去，也可以1，2，1，1，2这样子上去，总之走法有很多，那如何用编程求得总共有多少种走法呢？</p>\n<p>我们仔细想下，实际上，可以根据第一步的走法把所有走法分为两类，第一类是第一步走了1个台阶，另一类是第一步走了2个台阶。所以n个台阶的走法就等于先走1阶后，n-1个台阶的走法 加上先走2阶后，n-2个台阶的走法。用公式表示就是：</p>\n<pre><code>f(n) = f(n-1)+f(n-2)\n</code></pre>\n<p>有了递推公式，递归代码基本上就完成了一半。我们再来看下终止条件。当有一个台阶时，我们不需要再继续递归，就只有一种走法。所以f(1)=1。这个递归终止条件足够吗？我们可以用n=2，n=3这样比较小的数试验一下。</p>\n<p>n=2时，f(2)=f(1)+f(0)。如果递归终止条件只有一个f(1)=1，那f(2)就无法求解了。所以除了f(1)=1这一个递归终止条件外，还要有f(0)=1，表示走0个台阶有一种走法，不过这样子看起来就不符合正常的逻辑思维了。所以，我们可以把f(2)=2作为一种终止条件，表示走2个台阶，有两种走法，一步走完或者分两步来走。</p>\n<p>所以，递归终止条件就是f(1)=1，f(2)=2。这个时候，你可以再拿n=3，n=4来验证一下，这个终止条件是否足够并且正确。</p>\n<p>我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的：</p>\n<pre><code>f(1) = 1;\nf(2) = 2;\nf(n) = f(n-1)+f(n-2)\n</code></pre>\n<p>有了这个公式，我们转化成递归代码就简单多了。最终的递归代码是这样的：</p>\n<pre><code>int f(int n) {\n  if (n == 1) return 1;\n  if (n == 2) return 2;\n  return f(n-1) + f(n-2);\n}\n</code></pre>\n<p>我总结一下，<strong><span class=\"orange\">写递归代码的关键就是找到如何将大问题分解为小问题的规律，并且基于此写出递推公式，然后再推敲终止条件，最后将递推公式和终止条件翻译成代码</span></strong>。</p>\n<p>虽然我讲了这么多方法，但是作为初学者的你，现在是不是还是有种想不太清楚的感觉呢？实际上，我刚学递归的时候，也有这种感觉，这也是文章开头我说递归代码比较难理解的地方。</p>\n<p>刚讲的电影院的例子，我们的递归调用只有一个分支，也就是说“一个问题只需要分解为一个子问题”，我们很容易能够想清楚“递“和”归”的每一个步骤，所以写起来、理解起来都不难。</p>\n<p>但是，当我们面对的是一个问题要分解为多个子问题的情况，递归代码就没那么好理解了。</p>\n<p>像我刚刚讲的第二个例子，人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚。</p>\n<p>计算机擅长做重复的事情，所以递归正和它的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时，我们总想把递归平铺展开，脑子里就会循环，一层一层往下调，然后再一层一层返回，试图想搞清楚计算机每一步都是怎么执行的，这样就很容易被绕进去。</p>\n<p>对于递归代码，这种试图想清楚整个递和归过程的做法，实际上是进入了一个思维误区。很多时候，我们理解起来比较吃力，主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢？</p>\n<p>如果一个问题A可以分解为若干子问题B、C、D，你可以假设子问题B、C、D已经解决，在此基础上思考如何解决问题A。而且，你只需要思考问题A与子问题B、C、D两层之间的关系即可，不需要一层一层往下思考子问题与子子问题，子子问题与子子子问题之间的关系。屏蔽掉递归细节，这样子理解起来就简单多了。</p>\n<p>因此，<strong>编写递归代码的关键是，只要遇到递归，我们就把它抽象成一个递推公式，不用想一层层的调用关系，不要试图用人脑去分解递归的每个步骤</strong>。</p>\n<h2>递归代码要警惕堆栈溢出</h2>\n<p>在实际的软件开发中，编写递归代码时，我们会遇到很多问题，比如堆栈溢出。而堆栈溢出会造成系统性崩溃，后果会非常严重。为什么递归代码容易造成堆栈溢出呢？我们又该如何预防堆栈溢出呢？</p>\n<p>我在“栈”那一节讲过，函数调用会使用栈来保存临时变量。每调用一个函数，都会将临时变量封装为栈帧压入内存栈，等函数执行完成返回时，才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大，调用层次很深，一直压入栈，就会有堆栈溢出的风险。</p>\n<p>比如前面的讲到的电影院的例子，如果我们将系统栈或者JVM堆栈大小设置为1KB，在求解f(19999)时便会出现如下堆栈报错：</p>\n<pre><code>Exception in thread &quot;main&quot; java.lang.StackOverflowError\n</code></pre>\n<p>那么，如何避免出现堆栈溢出呢？</p>\n<p>我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度（比如1000）之后，我们就不继续往下再递归了，直接返回报错。还是电影院那个例子，我们可以改造成下面这样子，就可以避免堆栈溢出了。不过，我写的代码是伪代码，为了代码简洁，有些边界条件没有考虑，比如x&lt;=0。</p>\n<pre><code>// 全局变量，表示递归的深度。\nint depth = 0;\n\nint f(int n) {\n  ++depth；\n  if (depth &gt; 1000) throw exception;\n  \n  if (n == 1) return 1;\n  return f(n-1) + 1;\n}\n</code></pre>\n<p>但这种做法并不能完全解决问题，因为最大允许的递归深度跟当前线程剩余的栈空间大小有关，事先无法计算。如果实时计算，代码过于复杂，就会影响代码的可读性。所以，如果最大深度比较小，比如10、50，就可以用这种方法，否则这种方法并不是很实用。</p>\n<h2>递归代码要警惕重复计算</h2>\n<p>除此之外，使用递归时还会出现重复计算的问题。刚才我讲的第二个递归代码的例子，如果我们把整个递归过程分解一下的话，那就是这样的：</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/e7/bf/e7e778994e90265344f6ac9da39e01bf.jpg\" alt=\"\" /></p>\n<p>从图中，我们可以直观地看到，想要计算f(5)，需要先计算f(4)和f(3)，而计算f(4)还需要计算f(3)，因此，f(3)就被计算了很多次，这就是重复计算问题。</p>\n<p>为了避免重复计算，我们可以通过一个数据结构（比如散列表）来保存已经求解过的f(k)。当递归调用到f(k)时，先看下是否已经求解过了。如果是，则直接从散列表中取值返回，不需要重复计算，这样就能避免刚讲的问题了。</p>\n<p>按照上面的思路，我们来改造一下刚才的代码：</p>\n<pre><code>public int f(int n) {\n  if (n == 1) return 1;\n  if (n == 2) return 2;\n  \n  // hasSolvedList可以理解成一个Map，key是n，value是f(n)\n  if (hasSolvedList.containsKey(n)) {\n    return hasSovledList.get(n);\n  }\n  \n  int ret = f(n-1) + f(n-2);\n  hasSovledList.put(n, ret);\n  return ret;\n}\n</code></pre>\n<p>除了堆栈溢出、重复计算这两个常见的问题。递归代码还有很多别的问题。</p>\n<p>在时间效率上，递归代码里多了很多函数调用，当这些函数调用的数量较大时，就会积聚成一个可观的时间成本。在空间复杂度上，因为递归调用一次就会在内存栈中保存一次现场数据，所以在分析递归代码空间复杂度时，需要额外考虑这部分的开销，比如我们前面讲到的电影院递归代码，空间复杂度并不是O(1)，而是O(n)。</p>\n<h2>怎么将递归代码改写为非递归代码？</h2>\n<p>我们刚说了，递归有利有弊，利是递归代码的表达力很强，写起来非常简洁；而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以，在开发过程中，我们要根据实际情况来选择是否需要用递归的方式来实现。</p>\n<p>那我们是否可以把递归代码改写为非递归代码呢？比如刚才那个电影院的例子，我们抛开场景，只看f(x) =f(x-1)+1这个递推公式。我们这样改写看看：</p>\n<pre><code>int f(int n) {\n  int ret = 1;\n  for (int i = 2; i &lt;= n; ++i) {\n    ret = ret + 1;\n  }\n  return ret;\n}\n</code></pre>\n<p>同样，第二个例子也可以改为非递归的实现方式。</p>\n<pre><code>int f(int n) {\n  if (n == 1) return 1;\n  if (n == 2) return 2;\n  \n  int ret = 0;\n  int pre = 2;\n  int prepre = 1;\n  for (int i = 3; i &lt;= n; ++i) {\n    ret = pre + prepre;\n    prepre = pre;\n    pre = ret;\n  }\n  return ret;\n}\n</code></pre>\n<p>那是不是所有的递归代码都可以改为这种<strong>迭代循环</strong>的非递归写法呢？</p>\n<p>笼统地讲，是的。因为递归本身就是借助栈来实现的，只不过我们使用的栈是系统或者虚拟机本身提供的，我们没有感知罢了。如果我们自己在内存堆上实现栈，手动模拟入栈、出栈过程，这样任何递归代码都可以改写成看上去不是递归代码的样子。</p>\n<p>但是这种思路实际上是将递归改为了“手动”递归，本质并没有变，而且也并没有解决前面讲到的某些问题，徒增了实现的复杂度。</p>\n<h2>解答开篇</h2>\n<p>到此为止，递归相关的基础知识已经讲完了，咱们来看一下开篇的问题：如何找到“最终推荐人”？我的解决方案是这样的：</p>\n<pre><code>long findRootReferrerId(long actorId) {\n  Long referrerId = select referrer_id from [table] where actor_id = actorId;\n  if (referrerId == null) return actorId;\n  return findRootReferrerId(referrerId);\n}\n</code></pre>\n<p>是不是非常简洁？用三行代码就能搞定了，不过在实际项目中，上面的代码并不能工作，为什么呢？这里面有两个问题。</p>\n<p>第一，如果递归很深，可能会有堆栈溢出的问题。</p>\n<p>第二，如果数据库里存在脏数据，我们还需要处理由此产生的无限递归问题。比如demo环境下数据库中，测试工程师为了方便测试，会人为地插入一些数据，就会出现脏数据。如果A的推荐人是B，B的推荐人是C，C的推荐人是A，这样就会发生死循环。</p>\n<p>第一个问题，我前面已经解答过了，可以用限制递归深度来解决。第二个问题，也可以用限制递归深度来解决。不过，还有一个更高级的处理方法，就是自动检测A-B-C-A这种“环”的存在。如何来检测环的存在呢？这个我暂时不细说，你可以自己思考下，后面的章节我们还会讲。</p>\n<h2>内容小结</h2>\n<p>关于递归的知识，到这里就算全部讲完了。我来总结一下。</p>\n<p>递归是一种非常高效、简洁的编码技巧。只要是满足“三个条件”的问题就可以通过递归代码来解决。</p>\n<p>不过递归代码也比较难写、难理解。编写递归代码的关键就是不要把自己绕进去，正确姿势是写出递推公式，找出终止条件，然后再翻译成递归代码。</p>\n<p>递归代码虽然简洁高效，但是，递归代码也有很多弊端。比如，堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等，所以，在编写递归代码的时候，一定要控制好这些副作用。</p>\n<h2>课后思考</h2>\n<p>我们平时调试代码喜欢使用IDE的单步跟踪功能，像规模比较大、递归层次很深的递归代码，几乎无法使用这种调试方式。对于递归代码，你有什么好的调试方法呢？</p>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"10 |  递归：如何用三行代码找到“最终推荐人”？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/4e/bd/4e824679ae4b9a0141996d0cf6dcfabd.mp3",
		"column_id":126,
		"id":41440,
		"site_source_url":"https://time.geekbang.org/column/article/41440"
	},
	{
		"article_content":"<p>我们知道，CPU资源是有限的，任务的处理速度与线程个数并不是线性正相关。相反，过多的线程反而会导致CPU频繁切换，处理性能下降。所以，线程池的大小一般都是综合考虑要处理任务的特点和硬件环境，来事先设置的。</p><p><strong><span class=\"orange\">当我们向固定大小的线程池中请求一个线程时，如果线程池中没有空闲资源了，这个时候线程池如何处理这个请求？是拒绝请求还是排队请求？各种处理策略又是怎么实现的呢？</span></strong></p><p>实际上，这些问题并不复杂，其底层的数据结构就是我们今天要学的内容，队列（queue）。</p><h2>如何理解“队列”？</h2><p>队列这个概念非常好理解。你可以把它想象成排队买票，先来的先买，后来的人只能站末尾，不允许插队。<strong>先进者先出，这就是典型的“<strong><strong>队列</strong></strong>”</strong>。</p><p>我们知道，栈只支持两个基本操作：<strong>入栈push()<strong>和</strong>出栈pop()</strong>。队列跟栈非常相似，支持的操作也很有限，最基本的操作也是两个：<strong>入队enqueue()</strong>，放一个数据到队列尾部；<strong>出队dequeue()</strong>，从队列头部取一个元素。</p><p><img src=\"https://static001.geekbang.org/resource/image/9e/3e/9eca53f9b557b1213c5d94b94e9dce3e.jpg\" alt=\"\"></p><p>所以，队列跟栈一样，也是一种<strong>操作受限的线性表数据结构</strong>。</p><p>队列的概念很好理解，基本操作也很容易掌握。作为一种非常基础的数据结构，队列的应用也非常广泛，特别是一些具有某些额外特性的队列，比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中，起着关键性的作用。比如高性能队列Disruptor、Linux环形缓存，都用到了循环并发队列；Java concurrent并发包利用ArrayBlockingQueue来实现公平锁等。</p><!-- [[[read_end]]] --><h2>顺序队列和链式队列</h2><p>我们知道了，队列跟栈一样，也是一种抽象的数据结构。它具有先进先出的特性，支持在队尾插入元素，在队头删除元素，那究竟该如何实现一个队列呢？</p><p>跟栈一样，队列可以用数组来实现，也可以用链表来实现。用数组实现的栈叫作顺序栈，用链表实现的栈叫作链式栈。同样，用数组实现的队列叫作<strong>顺序队列</strong>，用链表实现的队列叫作<strong>链式队列</strong>。</p><p>我们先来看下基于数组的实现方法。我用Java语言实现了一下，不过并不包含Java语言的高级语法，而且我做了比较详细的注释，你应该可以看懂。</p><pre><code>// 用数组实现的队列\npublic class ArrayQueue {\n  // 数组：items，数组大小：n\n  private String[] items;\n  private int n = 0;\n  // head表示队头下标，tail表示队尾下标\n  private int head = 0;\n  private int tail = 0;\n\n  // 申请一个大小为capacity的数组\n  public ArrayQueue(int capacity) {\n    items = new String[capacity];\n    n = capacity;\n  }\n\n  // 入队\n  public boolean enqueue(String item) {\n    // 如果tail == n 表示队列已经满了\n    if (tail == n) return false;\n    items[tail] = item;\n    ++tail;\n    return true;\n  }\n\n  // 出队\n  public String dequeue() {\n    // 如果head == tail 表示队列为空\n    if (head == tail) return null;\n    // 为了让其他语言的同学看的更加明确，把--操作放到单独一行来写了\n    String ret = items[head];\n    ++head;\n    return ret;\n  }\n}\n</code></pre><p>比起栈的数组实现，队列的数组实现稍微有点儿复杂，但是没关系。我稍微解释一下实现思路，你很容易就能明白了。</p><p>对于栈来说，我们只需要一个<strong>栈顶指针</strong>就可以了。但是队列需要两个指针：一个是head指针，指向队头；一个是tail指针，指向队尾。</p><p>你可以结合下面这幅图来理解。当a、b、c、d依次入队之后，队列中的head指针指向下标为0的位置，tail指针指向下标为4的位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/5c/cb/5c0ec42eb797e8a7d48c9dbe89dc93cb.jpg\" alt=\"\"></p><p>当我们调用两次出队操作之后，队列中head指针指向下标为2的位置，tail指针仍然指向下标为4的位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/de/0d/dea27f2c505dd8d0b6b86e262d03430d.jpg\" alt=\"\"></p><p>你肯定已经发现了，随着不停地进行入队、出队操作，head和tail都会持续往后移动。当tail移动到最右边，即使数组中还有空闲空间，也无法继续往队列中添加数据了。这个问题该如何解决呢？</p><p>你是否还记得，在数组那一节，我们也遇到过类似的问题，就是数组的删除操作会导致数组中的数据不连续。你还记得我们当时是怎么解决的吗？对，用<strong>数据搬移</strong>！但是，每次进行出队操作都相当于删除数组下标为0的数据，要搬移整个队列中的数据，这样出队操作的时间复杂度就会从原来的O(1)变为O(n)。能不能优化一下呢？</p><p>实际上，我们在出队时可以不用搬移数据。如果没有空闲空间了，我们只需要在入队时，再集中触发一次数据的搬移操作。借助这个思想，出队函数dequeue()保持不变，我们稍加改造一下入队函数enqueue()的实现，就可以轻松解决刚才的问题了。下面是具体的代码：</p><pre><code>   // 入队操作，将item放入队尾\n  public boolean enqueue(String item) {\n    // tail == n表示队列末尾没有空间了\n    if (tail == n) {\n      // tail ==n &amp;&amp; head==0，表示整个队列都占满了\n      if (head == 0) return false;\n      // 数据搬移\n      for (int i = head; i &lt; tail; ++i) {\n        items[i-head] = items[i];\n      }\n      // 搬移完之后重新更新head和tail\n      tail -= head;\n      head = 0;\n    }\n    \n    items[tail] = item;\n    ++tail;\n    return true;\n  }\n</code></pre><p>从代码中我们看到，当队列的tail指针移动到数组的最右边后，如果有新的数据入队，我们可以将head到tail之间的数据，整体搬移到数组中0到tail-head的位置。</p><p><img src=\"https://static001.geekbang.org/resource/image/09/c7/094ba7722eeec46ead58b40c097353c7.jpg\" alt=\"\"></p><p>这种实现思路中，出队操作的时间复杂度仍然是O(1)，但入队操作的时间复杂度还是O(1)吗？你可以用我们第3节、第4节讲的算法复杂度分析方法，自己试着分析一下。</p><p>接下来，我们再来看下<strong>基于链表的队列实现方法</strong>。</p><p>基于链表的实现，我们同样需要两个指针：head指针和tail指针。它们分别指向链表的第一个结点和最后一个结点。如图所示，入队时，tail-&gt;next= new_node, tail = tail-&gt;next；出队时，head = head-&gt;next。我将具体的代码放到Github上，你可以自己试着实现一下，然后再去Github上跟我实现的代码对比下，看写得对不对。</p><p><img src=\"https://static001.geekbang.org/resource/image/c9/93/c916fe2212f8f543ddf539296444d393.jpg\" alt=\"\"></p><h2>循环队列</h2><p>我们刚才用数组来实现队列的时候，在tail==n时，会有数据搬移操作，这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢？我们来看看循环队列的解决思路。</p><p>循环队列，顾名思义，它长得像一个环。原本数组是有头有尾的，是一条直线。现在我们把首尾相连，扳成了一个环。我画了一张图，你可以直观地感受一下。</p><p><img src=\"https://static001.geekbang.org/resource/image/58/90/58ba37bb4102b87d66dffe7148b0f990.jpg\" alt=\"\"></p><p>我们可以看到，图中这个队列的大小为8，当前head=4，tail=7。当有一个新的元素a入队时，我们放入下标为7的位置。但这个时候，我们并不把tail更新为8，而是将其在环中后移一位，到下标为0的位置。当再有一个元素b入队时，我们将b放入下标为0的位置，然后tail加1更新为1。所以，在a，b依次入队之后，循环队列中的元素就变成了下面的样子：</p><p><img src=\"https://static001.geekbang.org/resource/image/71/80/71a41effb54ccea9dd463bde1b6abe80.jpg\" alt=\"\"></p><p>通过这样的方法，我们成功避免了数据搬移操作。看起来不难理解，但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要想写出没有bug的循环队列的实现代码，我个人觉得，最关键的是，<strong>确定好队空和队满的判定条件</strong>。</p><p>在用数组实现的非循环队列中，队满的判断条件是tail == n，队空的判断条件是head == tail。那针对循环队列，如何判断队空和队满呢？</p><p>队列为空的判断条件仍然是head == tail。但队列满的判断条件就稍微有点复杂了。我画了一张队列满的图，你可以看一下，试着总结一下规律。</p><p><img src=\"https://static001.geekbang.org/resource/image/3d/ec/3d81a44f8c42b3ceee55605f9aeedcec.jpg\" alt=\"\"></p><p>就像我图中画的队满的情况，tail=3，head=4，n=8，所以总结一下规律就是：(3+1)%8=4。多画几张队满的图，你就会发现，当队满时，<strong>(tail+1)%n=head</strong>。</p><p>你有没有发现，当队列满时，图中的tail指向的位置实际上是没有存储数据的。所以，循环队列会浪费一个数组的存储空间。</p><p>Talk is cheap，如果还是没怎么理解，那就show you code吧。</p><pre><code>public class CircularQueue {\n  // 数组：items，数组大小：n\n  private String[] items;\n  private int n = 0;\n  // head表示队头下标，tail表示队尾下标\n  private int head = 0;\n  private int tail = 0;\n\n  // 申请一个大小为capacity的数组\n  public CircularQueue(int capacity) {\n    items = new String[capacity];\n    n = capacity;\n  }\n\n  // 入队\n  public boolean enqueue(String item) {\n    // 队列满了\n    if ((tail + 1) % n == head) return false;\n    items[tail] = item;\n    tail = (tail + 1) % n;\n    return true;\n  }\n\n  // 出队\n  public String dequeue() {\n    // 如果head == tail 表示队列为空\n    if (head == tail) return null;\n    String ret = items[head];\n    head = (head + 1) % n;\n    return ret;\n  }\n}\n</code></pre><h2>阻塞队列和并发队列</h2><p>前面讲的内容理论比较多，看起来很难跟实际的项目开发扯上关系。确实，队列这种数据结构很基础，平时的业务开发不大可能从零实现一个队列，甚至都不会直接用到。而一些具有特殊特性的队列应用却比较广泛，比如阻塞队列和并发队列。</p><p><strong>阻塞队列</strong>其实就是在队列基础上增加了阻塞操作。简单来说，就是在队列为空的时候，从队头取数据会被阻塞。因为此时还没有数据可取，直到队列中有了数据才能返回；如果队列已经满了，那么插入数据的操作就会被阻塞，直到队列中有空闲位置后再插入数据，然后再返回。</p><p><img src=\"https://static001.geekbang.org/resource/image/5e/eb/5ef3326181907dea0964f612890185eb.jpg\" alt=\"\"></p><p>你应该已经发现了，上述的定义就是一个“生产者-消费者模型”！是的，我们可以使用阻塞队列，轻松实现一个“生产者-消费者模型”！</p><p>这种基于阻塞队列实现的“生产者-消费者模型”，可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快，“消费者”来不及消费时，存储数据的队列很快就会满了。这个时候，生产者就阻塞等待，直到“消费者”消费了数据，“生产者”才会被唤醒继续“生产”。</p><p>而且不仅如此，基于阻塞队列，我们还可以通过协调“生产者”和“消费者”的个数，来提高数据的处理效率。比如前面的例子，我们可以多配置几个“消费者”，来应对一个“生产者”。</p><p><img src=\"https://static001.geekbang.org/resource/image/9f/67/9f539cc0f1edc20e7fa6559193898067.jpg\" alt=\"\"></p><p>前面我们讲了阻塞队列，在多线程情况下，会有多个线程同时操作队列，这个时候就会存在线程安全问题，那如何实现一个线程安全的队列呢？</p><p>线程安全的队列我们叫作<strong>并发队列</strong>。最简单直接的实现方式是直接在enqueue()、dequeue()方法上加锁，但是锁粒度大并发度会比较低，同一时刻仅允许一个存或者取操作。实际上，基于数组的循环队列，利用CAS原子操作，可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。在实战篇讲Disruptor的时候，我会再详细讲并发队列的应用。</p><h2>解答开篇</h2><p>队列的知识就讲完了，我们现在回过来看下开篇的问题。线程池没有空闲线程时，新的任务请求线程资源时，线程池该如何处理？各种处理策略又是如何实现的呢？</p><p>我们一般有两种处理策略。第一种是非阻塞的处理方式，直接拒绝任务请求；另一种是阻塞的处理方式，将请求排队，等到有空闲线程时，取出排队的请求继续处理。那如何存储排队的请求呢？</p><p>我们希望公平地处理每个排队的请求，先进者先服务，所以队列这种数据结构很适合来存储排队请求。我们前面说过，队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢？</p><p>基于链表的实现方式，可以实现一个支持无限排队的无界队列（unbounded queue），但是可能会导致过多的请求排队等待，请求处理的响应时间过长。所以，针对响应时间比较敏感的系统，基于链表实现的无限排队的线程池是不合适的。</p><p>而基于数组实现的有界队列（bounded queue），队列的大小有限，所以线程池中排队的请求超过队列大小时，接下来的请求就会被拒绝，这种方式对响应时间敏感的系统来说，就相对更加合理。不过，设置一个合理的队列大小，也是非常有讲究的。队列太大导致等待的请求太多，队列太小会导致无法充分利用系统资源、发挥最大性能。</p><p>除了前面讲到队列应用在线程池请求排队的场景之外，队列可以应用在任何有限资源池中，用于排队请求，比如数据库连接池等。<strong>实际上，对于大部分资源有限的场景，当没有空闲资源时，基本上都可以通过“队列”这种数据结构来实现请求排队。</strong></p><h2>内容小结</h2><p>今天我们讲了一种跟栈很相似的数据结构，队列。关于队列，你能掌握下面的内容，这节就没问题了。</p><p>队列最大的特点就是先进先出，主要的两个操作是入队和出队。跟栈一样，它既可以用数组来实现，也可以用链表来实现。用数组实现的叫顺序队列，用链表实现的叫链式队列。特别是长得像一个环的循环队列。在数组实现队列的时候，会有数据搬移操作，要想解决数据搬移的问题，我们就需要像环一样的循环队列。</p><p>循环队列是我们这节的重点。要想写出没有bug的循环队列实现代码，关键要确定好队空和队满的判定条件，具体的代码你要能写出来。</p><p>除此之外，我们还讲了几种高级的队列结构，阻塞队列、并发队列，底层都还是队列这种数据结构，只不过在之上附加了很多其他功能。阻塞队列就是入队、出队操作可以阻塞，并发队列就是队列的操作多线程安全。</p><h2>课后思考</h2><ol>\n<li>\n<p>除了线程池这种池结构会用到队列排队请求，你还知道有哪些类似的池结构或者场景中会用到队列的排队请求呢？</p>\n</li>\n<li>\n<p>今天讲到并发队列，关于如何实现无锁并发队列，网上有非常多的讨论。对这个问题，你怎么看呢？</p>\n</li>\n</ol><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"09 | 队列：队列在线程池等有限资源池中的应用",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/6a/a9/6a4c90b8308ae93492d041c4480b11a9.mp3",
		"column_id":126,
		"id":41330,
		"site_source_url":"https://time.geekbang.org/column/article/41330"
	},
	{
		"article_content":"<p>浏览器的前进、后退功能，我想你肯定很熟悉吧？</p><p>当你依次访问完一串页面a-b-c之后，点击浏览器的后退按钮，就可以查看之前浏览过的页面b和a。当你后退到页面a，点击前进按钮，就可以重新查看页面b和c。但是，如果你后退到页面b后，点击了新的页面d，那就无法再通过前进、后退功能查看页面c了。</p><p><strong><span class=\"orange\">假设你是Chrome浏览器的开发工程师，你会如何实现这个功能呢？</span></strong></p><p>这就要用到我们今天要讲的“栈”这种数据结构。带着这个问题，我们来学习今天的内容。</p><h2>如何理解“栈”？</h2><p>关于“栈”，我有一个非常贴切的例子，就是一摞叠在一起的盘子。我们平时放盘子的时候，都是从下往上一个一个放；取的时候，我们也是从上往下一个一个地依次取，不能从中间任意抽出。<strong>后进者先出，先进者后出，这就是典型的“栈”结构。</strong></p><p><img src=\"https://static001.geekbang.org/resource/image/3e/0b/3e20cca032c25168d3cc605fa7a53a0b.jpg\" alt=\"\"></p><p>从栈的操作特性上来看，<strong>栈是一种“操作受限”的线性表</strong>，只允许在一端插入和删除数据。</p><p>我第一次接触这种数据结构的时候，就对它存在的意义产生了很大的疑惑。因为我觉得，相比数组和链表，栈带给我的只有限制，并没有任何优势。那我直接使用数组或者链表不就好了吗？为什么还要用这个“操作受限”的“栈”呢？</p><p>事实上，从功能上来说，数组或链表确实可以替代栈，但你要知道，特定的数据结构是对特定场景的抽象，而且，数组或链表暴露了太多的操作接口，操作上的确灵活自由，但使用时就比较不可控，自然也就更容易出错。</p><!-- [[[read_end]]] --><p><strong>当某个数据集合只涉及在一端插入和删除数据，并且满足后进先出、先进后出的特性，我们就应该首选“栈”这种数据结构</strong>。</p><h2>如何实现一个“栈”？</h2><p>从刚才栈的定义里，我们可以看出，栈主要包含两个操作，入栈和出栈，也就是在栈顶插入一个数据和从栈顶删除一个数据。理解了栈的定义之后，我们来看一看如何用代码实现一个栈。</p><p>实际上，栈既可以用数组来实现，也可以用链表来实现。用数组实现的栈，我们叫作<strong>顺序栈</strong>，用链表实现的栈，我们叫作<strong>链式栈</strong>。</p><p>我这里实现一个基于数组的顺序栈。基于链表实现的链式栈的代码，你可以自己试着写一下。我会将我写好的代码放到Github上，你可以去看一下自己写的是否正确。</p><p>我这段代码是用Java来实现的，但是不涉及任何高级语法，并且我还用中文做了详细的注释，所以你应该是可以看懂的。</p><pre><code>// 基于数组实现的顺序栈\npublic class ArrayStack {\n  private String[] items;  // 数组\n  private int count;       // 栈中元素个数\n  private int n;           //栈的大小\n\n  // 初始化数组，申请一个大小为n的数组空间\n  public ArrayStack(int n) {\n    this.items = new String[n];\n    this.n = n;\n    this.count = 0;\n  }\n\n  // 入栈操作\n  public boolean push(String item) {\n    // 数组空间不够了，直接返回false，入栈失败。\n    if (count == n) return false;\n    // 将item放到下标为count的位置，并且count加一\n    items[count] = item;\n    ++count;\n    return true;\n  }\n  \n  // 出栈操作\n  public String pop() {\n    // 栈为空，则直接返回null\n    if (count == 0) return null;\n    // 返回下标为count-1的数组元素，并且栈中元素个数count减一\n    String tmp = items[count-1];\n    --count;\n    return tmp;\n  }\n}\n</code></pre><p>了解了定义和基本操作，那它的操作的时间、空间复杂度是多少呢？</p><p>不管是顺序栈还是链式栈，我们存储数据只需要一个大小为n的数组就够了。在入栈和出栈过程中，只需要一两个临时变量存储空间，所以空间复杂度是O(1)。</p><p>注意，这里存储数据需要一个大小为n的数组，并不是说空间复杂度就是O(n)。因为，这n个空间是必须的，无法省掉。所以我们说空间复杂度的时候，是指除了原本的数据存储空间外，算法运行还需要额外的存储空间。</p><p>空间复杂度分析是不是很简单？时间复杂度也不难。不管是顺序栈还是链式栈，入栈、出栈只涉及栈顶个别数据的操作，所以时间复杂度都是O(1)。</p><h2>支持动态扩容的顺序栈</h2><p>刚才那个基于数组实现的栈，是一个固定大小的栈，也就是说，在初始化栈时需要事先指定栈的大小。当栈满之后，就无法再往栈里添加数据了。尽管链式栈的大小不受限，但要存储next指针，内存消耗相对较多。那我们如何基于数组实现一个可以支持动态扩容的栈呢？</p><p>你还记得，我们在数组那一节，是如何来实现一个支持动态扩容的数组的吗？当数组空间不够时，我们就重新申请一块更大的内存，将原来数组中数据统统拷贝过去。这样就实现了一个支持动态扩容的数组。</p><p>所以，如果要实现一个支持动态扩容的栈，我们只需要底层依赖一个支持动态扩容的数组就可以了。当栈满了之后，我们就申请一个更大的数组，将原来的数据搬移到新数组中。我画了一张图，你可以对照着理解一下。</p><p><img src=\"https://static001.geekbang.org/resource/image/b1/da/b193adf5db4356d8ab35a1d32142b3da.jpg\" alt=\"\"></p><p>实际上，支持动态扩容的顺序栈，我们平时开发中并不常用到。我讲这一块的目的，主要还是希望带你练习一下前面讲的复杂度分析方法。所以这一小节的重点是复杂度分析。</p><p>你不用死记硬背入栈、出栈的时间复杂度，你需要掌握的是分析方法。能够自己分析才算是真正掌握了。现在我就带你分析一下支持动态扩容的顺序栈的入栈、出栈操作的时间复杂度。</p><p>对于出栈操作来说，我们不会涉及内存的重新申请和数据的搬移，所以出栈的时间复杂度仍然是O(1)。但是，对于入栈操作来说，情况就不一样了。当栈中有空闲空间时，入栈操作的时间复杂度为O(1)。但当空间不够时，就需要重新申请内存和数据搬移，所以时间复杂度就变成了O(n)。</p><p>也就是说，对于入栈操作来说，最好情况时间复杂度是O(1)，最坏情况时间复杂度是O(n)。那平均情况下的时间复杂度又是多少呢？还记得我们在复杂度分析那一节中讲的摊还分析法吗？这个入栈操作的平均情况下的时间复杂度可以用摊还分析法来分析。我们也正好借此来实战一下摊还分析法。</p><p>为了分析的方便，我们需要事先做一些假设和定义：</p><ul>\n<li>\n<p>栈空间不够时，我们重新申请一个是原来大小两倍的数组；</p>\n</li>\n<li>\n<p>为了简化分析，假设只有入栈操作没有出栈操作；</p>\n</li>\n<li>\n<p>定义不涉及内存搬移的入栈操作为simple-push操作，时间复杂度为O(1)。</p>\n</li>\n</ul><p>如果当前栈大小为K，并且已满，当再有新的数据要入栈时，就需要重新申请2倍大小的内存，并且做K个数据的搬移操作，然后再入栈。但是，接下来的K-1次入栈操作，我们都不需要再重新申请内存和搬移数据，所以这K-1次入栈操作都只需要一个simple-push操作就可以完成。为了让你更加直观地理解这个过程，我画了一张图。</p><p><img src=\"https://static001.geekbang.org/resource/image/c9/bb/c936a39ad54a9fdf526e805dc18cf6bb.jpg\" alt=\"\"></p><p>你应该可以看出来，这K次入栈操作，总共涉及了K个数据的搬移，以及K次simple-push操作。将K个数据搬移均摊到K次入栈操作，那每个入栈操作只需要一个数据搬移和一个simple-push操作。以此类推，入栈操作的均摊时间复杂度就为O(1)。</p><p>通过这个例子的实战分析，也印证了前面讲到的，均摊时间复杂度一般都等于最好情况时间复杂度。因为在大部分情况下，入栈操作的时间复杂度O都是O(1)，只有在个别时刻才会退化为O(n)，所以把耗时多的入栈操作的时间均摊到其他入栈操作上，平均情况下的耗时就接近O(1)。</p><h2>栈在函数调用中的应用</h2><p>前面我讲的都比较偏理论，我们现在来看下，栈在软件工程中的实际应用。栈作为一个比较基础的数据结构，应用场景还是蛮多的。其中，比较经典的一个应用场景就是<strong>函数调用栈</strong>。</p><p>我们知道，操作系统给每个线程分配了一块独立的内存空间，这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数，就会将临时变量作为一个栈帧入栈，当被调用函数执行完成，返回之后，将这个函数对应的栈帧出栈。为了让你更好地理解，我们一块来看下这段代码的执行过程。</p><pre><code>int main() {\n   int a = 1; \n   int ret = 0;\n   int res = 0;\n   ret = add(3, 5);\n   res = a + ret;\n   printf(&quot;%d&quot;, res);\n   reuturn 0;\n}\n\nint add(int x, int y) {\n   int sum = 0;\n   sum = x + y;\n   return sum;\n}\n</code></pre><p>从代码中我们可以看出，main()函数调用了add()函数，获取计算结果，并且与临时变量a相加，最后打印res的值。为了让你清晰地看到这个过程对应的函数栈里出栈、入栈的操作，我画了一张图。图中显示的是，在执行到add()函数时，函数调用栈的情况。</p><p><img src=\"https://static001.geekbang.org/resource/image/17/1c/17b6c6711e8d60b61d65fb0df5559a1c.jpg\" alt=\"\"></p><h2>栈在表达式求值中的应用</h2><p>我们再来看栈的另一个常见的应用场景，编译器如何利用栈来实现<strong>表达式求值</strong>。</p><p>为了方便解释，我将算术表达式简化为只包含加减乘除四则运算，比如：34+13*9+44-12/3。对于这个四则运算，我们人脑可以很快求解出答案，但是对于计算机来说，理解这个表达式本身就是个挺难的事儿。如果换作你，让你来实现这样一个表达式求值的功能，你会怎么做呢？</p><p>实际上，编译器就是通过两个栈来实现的。其中一个保存操作数的栈，另一个是保存运算符的栈。我们从左向右遍历表达式，当遇到数字，我们就直接压入操作数栈；当遇到运算符，就与运算符栈的栈顶元素进行比较。</p><p>如果比运算符栈顶元素的优先级高，就将当前运算符压入栈；如果比运算符栈顶元素的优先级低或者相同，从运算符栈中取栈顶运算符，从操作数栈的栈顶取2个操作数，然后进行计算，再把计算完的结果压入操作数栈，继续比较。</p><p>我将3+5*8-6这个表达式的计算过程画成了一张图，你可以结合图来理解我刚讲的计算过程。</p><p><img src=\"https://static001.geekbang.org/resource/image/bc/00/bc77c8d33375750f1700eb7778551600.jpg\" alt=\"\"></p><p>这样用两个栈来解决的思路是不是非常巧妙？你有没有想到呢？</p><h2>栈在括号匹配中的应用</h2><p>除了用栈来实现表达式求值，我们还可以借助栈来检查表达式中的括号是否匹配。</p><p>我们同样简化一下背景。我们假设表达式中只包含三种括号，圆括号()、方括号[]和花括号{}，并且它们可以任意嵌套。比如，{<a href=\"\"></a>[{}]}或[{()}([])]等都为合法格式，而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串，如何检查它是否合法呢？</p><p>这里也可以用栈来解决。我们用栈来保存未匹配的左括号，从左到右依次扫描字符串。当扫描到左括号时，则将其压入栈中；当扫描到右括号时，从栈顶取出一个左括号。如果能够匹配，比如“(”跟“)”匹配，“[”跟“]”匹配，“{”跟“}”匹配，则继续扫描剩下的字符串。如果扫描的过程中，遇到不能配对的右括号，或者栈中没有数据，则说明为非法格式。</p><p>当所有的括号都扫描完成之后，如果栈为空，则说明字符串为合法格式；否则，说明有未匹配的左括号，为非法格式。</p><h2>解答开篇</h2><p>好了，我想现在你已经完全理解了栈的概念。我们再回来看看开篇的思考题，如何实现浏览器的前进、后退功能？其实，用两个栈就可以非常完美地解决这个问题。</p><p>我们使用两个栈，X和Y，我们把首次浏览的页面依次压入栈X，当点击后退按钮时，再依次从栈X中出栈，并将出栈的数据依次放入栈Y。当我们点击前进按钮时，我们依次从栈Y中取出数据，放入栈X中。当栈X中没有数据时，那就说明没有页面可以继续后退浏览了。当栈Y中没有数据，那就说明没有页面可以点击前进按钮浏览了。</p><p>比如你顺序查看了a，b，c三个页面，我们就依次把a，b，c压入栈，这个时候，两个栈的数据就是这个样子：</p><p><img src=\"https://static001.geekbang.org/resource/image/4b/3d/4b579a76ea7ebfc5abae2ad6ae6a3c3d.jpg\" alt=\"\"></p><p>当你通过浏览器的后退按钮，从页面c后退到页面a之后，我们就依次把c和b从栈X中弹出，并且依次放入到栈Y。这个时候，两个栈的数据就是这个样子：</p><p><img src=\"https://static001.geekbang.org/resource/image/b5/1b/b5e496e2e28fe08f0388958a0e12861b.jpg\" alt=\"\"></p><p>这个时候你又想看页面b，于是你又点击前进按钮回到b页面，我们就把b再从栈Y中出栈，放入栈X中。此时两个栈的数据是这个样子：</p><p><img src=\"https://static001.geekbang.org/resource/image/ea/bc/ea804125bea25d25ba467a51fb98c4bc.jpg\" alt=\"\"></p><p>这个时候，你通过页面b又跳转到新的页面d了，页面c就无法再通过前进、后退按钮重复查看了，所以需要清空栈Y。此时两个栈的数据这个样子：</p><p><img src=\"https://static001.geekbang.org/resource/image/a3/2e/a3c926fe3050d9a741f394f20430692e.jpg\" alt=\"\"></p><h2>内容小结</h2><p>我们来回顾一下今天讲的内容。栈是一种操作受限的数据结构，只支持入栈和出栈操作。后进先出是它最大的特点。栈既可以通过数组实现，也可以通过链表来实现。不管基于数组还是链表，入栈、出栈的时间复杂度都为O(1)。除此之外，我们还讲了一种支持动态扩容的顺序栈，你需要重点掌握它的均摊时间复杂度分析方法。</p><h2>课后思考</h2><ol>\n<li>\n<p>我们在讲栈的应用时，讲到用函数调用栈来保存临时变量，为什么函数调用要用“栈”来保存临时变量呢？用其他数据结构不行吗？</p>\n</li>\n<li>\n<p>我们都知道，JVM内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用，堆内存用来存储Java中的对象。那JVM里面的“栈”跟我们这里说的“栈”是不是一回事呢？如果不是，那它为什么又叫作“栈”呢？</p>\n</li>\n</ol><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"08 | 栈：如何实现浏览器的前进和后退功能？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/93/fa/93e51471ee6490ce48d32241fe2d6dfa.mp3",
		"column_id":126,
		"id":41222,
		"site_source_url":"https://time.geekbang.org/column/article/41222"
	},
	{
		"article_content":"<p>上一节我讲了链表相关的基础知识。学完之后，我看到有人留言说，基础知识我都掌握了，但是写链表代码还是很费劲。哈哈，的确是这样的！</p><p>想要写好链表代码并不是容易的事儿，尤其是那些复杂的链表操作，比如链表反转、有序链表合并等，写的时候非常容易出错。从我上百场面试的经验来看，能把“链表反转”这几行代码写对的人不足10%。</p><p>为什么链表代码这么难写？究竟怎样才能比较轻松地写出正确的链表代码呢？</p><p>只要愿意投入时间，我觉得大多数人都是可以学会的。比如说，如果你真的能花上一个周末或者一整天的时间，就去写链表反转这一个代码，多写几遍，一直练到能毫不费力地写出Bug free的代码。这个坎还会很难跨吗？</p><p>当然，自己有决心并且付出精力是成功的先决条件，除此之外，我们还需要一些方法和技巧。我根据自己的学习经历和工作经验，总结了<strong>几个写链表代码技巧</strong>。如果你能熟练掌握这几个技巧，加上你的主动和坚持，轻松拿下链表代码完全没有问题。</p><h2>技巧一：理解指针或引用的含义</h2><p>事实上，看懂链表的结构并不是很难，但是一旦把它和指针混在一起，就很容易让人摸不着头脑。所以，要想写对链表代码，首先就要理解好指针。</p><p>我们知道，有些语言有“指针”的概念，比如C语言；有些语言没有指针，取而代之的是“引用”，比如Java、Python。不管是“指针”还是“引用”，实际上，它们的意思都是一样的，都是存储所指对象的内存地址。</p><!-- [[[read_end]]] --><p>接下来，我会拿C语言中的“指针”来讲解，如果你用的是Java或者其他没有指针的语言也没关系，你把它理解成“引用”就可以了。</p><p>实际上，对于指针的理解，你只需要记住下面这句话就可以了：</p><p><strong>将某个变量赋值给指针，实际上就是将这个变量的地址赋值给指针，或者反过来说，指针中存储了这个变量的内存地址，指向了这个变量，通过指针就能找到这个变量。</strong></p><p>这句话听起来还挺拗口的，你可以先记住。我们回到链表代码的编写过程中，我来慢慢给你解释。</p><p>在编写链表代码的时候，我们经常会有这样的代码：p-&gt;next=q。这行代码是说，p结点中的next指针存储了q结点的内存地址。</p><p>还有一个更复杂的，也是我们写链表代码经常会用到的：p-&gt;next=p-&gt;next-&gt;next。这行代码表示，p结点的next指针存储了p结点的下下一个结点的内存地址。</p><p>掌握了指针或引用的概念，你应该可以很轻松地看懂链表代码。恭喜你，已经离写出链表代码近了一步！</p><h2>技巧二：警惕指针丢失和内存泄漏</h2><p>不知道你有没有这样的感觉，写链表代码的时候，指针指来指去，一会儿就不知道指到哪里了。所以，我们在写的时候，一定注意不要弄丢了指针。</p><p>指针往往都是怎么弄丢的呢？我拿单链表的插入操作为例来给你分析一下。</p><p><img src=\"https://static001.geekbang.org/resource/image/05/6e/05a4a3b57502968930d517c934347c6e.jpg\" alt=\"\"></p><p>如图所示，我们希望在结点a和相邻的结点b之间插入结点x，假设当前指针p指向结点a。如果我们将代码实现变成下面这个样子，就会发生指针丢失和内存泄露。</p><pre><code>p-&gt;next = x;  // 将p的next指针指向x结点；\nx-&gt;next = p-&gt;next;  // 将x的结点的next指针指向b结点；\n</code></pre><p>初学者经常会在这儿犯错。p-&gt;next指针在完成第一步操作之后，已经不再指向结点b了，而是指向结点x。第2行代码相当于将x赋值给x-&gt;next，自己指向自己。因此，整个链表也就断成了两半，从结点b往后的所有结点都无法访问到了。</p><p>对于有些语言来说，比如C语言，内存管理是由程序员负责的，如果没有手动释放结点对应的内存空间，就会产生内存泄露。所以，我们<strong>插入结点时，一定要注意操作的顺序</strong>，要先将结点x的next指针指向结点b，再把结点a的next指针指向结点x，这样才不会丢失指针，导致内存泄漏。所以，对于刚刚的插入代码，我们只需要把第1行和第2行代码的顺序颠倒一下就可以了。</p><p>同理，<strong>删除链表结点时，也一定要记得手动释放内存空间</strong>，否则，也会出现内存泄漏的问题。当然，对于像Java这种虚拟机自动管理内存的编程语言来说，就不需要考虑这么多了。</p><h2>技巧三：利用哨兵简化实现难度</h2><p>首先，我们先来回顾一下单链表的插入和删除操作。如果我们在结点p后面插入一个新的结点，只需要下面两行代码就可以搞定。</p><pre><code>new_node-&gt;next = p-&gt;next;\np-&gt;next = new_node;\n</code></pre><p>但是，当我们要向一个空链表中插入第一个结点，刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理，其中head表示链表的头结点。所以，从这段代码，我们可以发现，对于单链表的插入操作，第一个结点和其他结点的插入逻辑是不一样的。</p><pre><code>if (head == null) {\n  head = new_node;\n}\n</code></pre><p>我们再来看单链表结点删除操作。如果要删除结点p的后继结点，我们只需要一行代码就可以搞定。</p><pre><code>p-&gt;next = p-&gt;next-&gt;next;\n</code></pre><p>但是，如果我们要删除链表中的最后一个结点，前面的删除代码就不work了。跟插入类似，我们也需要对于这种情况特殊处理。写成代码是这样子的：</p><pre><code>if (head-&gt;next == null) {\n   head = null;\n}\n</code></pre><p>从前面的一步一步分析，我们可以看出，<strong>针对链表的插入、删除操作，需要对插入第一个结点和删除最后一个结点的情况进行特殊处理</strong>。这样代码实现起来就会很繁琐，不简洁，而且也容易因为考虑不全而出错。如何来解决这个问题呢？</p><p>技巧三中提到的哨兵就要登场了。哨兵，解决的是国家之间的边界问题。同理，这里说的哨兵也是解决“边界问题”的，不直接参与业务逻辑。</p><p>还记得如何表示一个空链表吗？head=null表示链表中没有结点了。其中head表示头结点指针，指向链表中的第一个结点。</p><p>如果我们引入哨兵结点，在任何时候，不管链表是不是空，head指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫<strong>带头链表</strong>。相反，没有哨兵结点的链表就叫作<strong>不带头链表</strong>。</p><p>我画了一个带头链表，你可以发现，哨兵结点是不存储数据的。因为哨兵结点一直存在，所以插入第一个结点和插入其他结点，删除最后一个结点和删除其他结点，都可以统一为相同的代码实现逻辑了。</p><p><img src=\"https://static001.geekbang.org/resource/image/7d/c7/7d22d9428bdbba96bfe388fe1e3368c7.jpg\" alt=\"\"></p><p>实际上，这种利用哨兵简化编程难度的技巧，在很多代码实现中都有用到，比如插入排序、归并排序、动态规划等。这些内容我们后面才会讲，现在为了让你感受更深，我再举一个非常简单的例子。代码我是用C语言实现的，不涉及语言方面的高级语法，很容易看懂，你可以类比到你熟悉的语言。</p><p>代码一：</p><pre><code>// 在数组a中，查找key，返回key所在的位置\n// 其中，n表示数组a的长度\nint find(char* a, int n, char key) {\n  // 边界条件处理，如果a为空，或者n&lt;=0，说明数组中没有数据，就不用while循环比较了\n  if(a == null || n &lt;= 0) {\n    return -1;\n  }\n  \n  int i = 0;\n  // 这里有两个比较操作：i&lt;n和a[i]==key.\n  while (i &lt; n) {\n    if (a[i] == key) {\n      return i;\n    }\n    ++i;\n  }\n  \n  return -1;\n}\n</code></pre><p>代码二：</p><pre><code>// 在数组a中，查找key，返回key所在的位置\n// 其中，n表示数组a的长度\n// 我举2个例子，你可以拿例子走一下代码\n// a = {4, 2, 3, 5, 9, 6}  n=6 key = 7\n// a = {4, 2, 3, 5, 9, 6}  n=6 key = 6\nint find(char* a, int n, char key) {\n  if(a == null || n &lt;= 0) {\n    return -1;\n  }\n  \n  // 这里因为要将a[n-1]的值替换成key，所以要特殊处理这个值\n  if (a[n-1] == key) {\n    return n-1;\n  }\n  \n  // 把a[n-1]的值临时保存在变量tmp中，以便之后恢复。tmp=6。\n  // 之所以这样做的目的是：希望find()代码不要改变a数组中的内容\n  char tmp = a[n-1];\n  // 把key的值放到a[n-1]中，此时a = {4, 2, 3, 5, 9, 7}\n  a[n-1] = key;\n  \n  int i = 0;\n  // while 循环比起代码一，少了i&lt;n这个比较操作\n  while (a[i] != key) {\n    ++i;\n  }\n  \n  // 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}\n  a[n-1] = tmp;\n  \n  if (i == n-1) {\n    // 如果i == n-1说明，在0...n-2之间都没有key，所以返回-1\n    return -1;\n  } else {\n    // 否则，返回i，就是等于key值的元素的下标\n    return i;\n  }\n}\n</code></pre><p>对比两段代码，在字符串a很长的时候，比如几万、几十万，你觉得哪段代码运行得更快点呢？答案是代码二，因为两段代码中执行次数最多就是while循环那一部分。第二段代码中，我们通过一个哨兵a[n-1] = key，成功省掉了一个比较语句i&lt;n，不要小看这一条语句，当累积执行万次、几十万次时，累积的时间就很明显了。</p><p>当然，这只是为了举例说明哨兵的作用，你写代码的时候千万不要写第二段那样的代码，因为可读性太差了。大部分情况下，我们并不需要如此追求极致的性能。</p><h2>技巧四：重点留意边界条件处理</h2><p>软件开发中，代码在一些边界或者异常情况下，最容易产生Bug。链表代码也不例外。要实现没有Bug的链表代码，一定要在编写的过程中以及编写完成之后，检查边界条件是否考虑全面，以及代码在边界条件下是否能正确运行。</p><p>我经常用来检查链表代码是否正确的边界条件有这样几个：</p><ul>\n<li>\n<p>如果链表为空时，代码是否能正常工作？</p>\n</li>\n<li>\n<p>如果链表只包含一个结点时，代码是否能正常工作？</p>\n</li>\n<li>\n<p>如果链表只包含两个结点时，代码是否能正常工作？</p>\n</li>\n<li>\n<p>代码逻辑在处理头结点和尾结点的时候，是否能正常工作？</p>\n</li>\n</ul><p>当你写完链表代码之后，除了看下你写的代码在正常的情况下能否工作，还要看下在上面我列举的几个边界条件下，代码仍然能否正确工作。如果这些边界条件下都没有问题，那基本上可以认为没有问题了。</p><p>当然，边界条件不止我列举的那些。针对不同的场景，可能还有特定的边界条件，这个需要你自己去思考，不过套路都是一样的。</p><p>实际上，不光光是写链表代码，你在写任何代码时，也千万不要只是实现业务正常情况下的功能就好了，一定要多想想，你的代码在运行的时候，可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对，这样写出来的代码才够健壮！</p><h2>技巧五：举例画图，辅助思考</h2><p>对于稍微复杂的链表操作，比如前面我们提到的单链表反转，指针一会儿指这，一会儿指那，一会儿就被绕晕了。总感觉脑容量不够，想不清楚。所以这个时候就要使用大招了，<strong>举例法</strong>和<strong>画图法</strong>。</p><p>你可以找一个具体的例子，把它画在纸上，释放一些脑容量，留更多的给逻辑思考，这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作，我一般都是把各种情况都举一个例子，画出插入前和插入后的链表变化，如图所示：</p><p><img src=\"https://static001.geekbang.org/resource/image/4a/f8/4a701dd79b59427be654261805b349f8.jpg\" alt=\"\"></p><p>看图写代码，是不是就简单多啦？而且，当我们写完代码之后，也可以举几个例子，画在纸上，照着代码走一遍，很容易就能发现代码中的Bug。</p><h2>技巧六：多写多练，没有捷径</h2><p>如果你已经理解并掌握了我前面所讲的方法，但是手写链表代码还是会出现各种各样的错误，也不要着急。因为我最开始学的时候，这种状况也持续了一段时间。</p><p>现在我写这些代码，简直就和“玩儿”一样，其实也没有什么技巧，就是把常见的链表操作都自己多写几遍，出问题就一点一点调试，熟能生巧！</p><p>所以，我精选了5个常见的链表操作。你只要把这几个操作都能写熟练，不熟就多写几遍，我保证你之后再也不会害怕写链表代码。</p><ul>\n<li>\n<p>单链表反转</p>\n</li>\n<li>\n<p>链表中环的检测</p>\n</li>\n<li>\n<p>两个有序的链表合并</p>\n</li>\n<li>\n<p>删除链表倒数第n个结点</p>\n</li>\n<li>\n<p>求链表的中间结点</p>\n</li>\n</ul><h2>内容小结</h2><p>这节我主要和你讲了写出正确链表代码的六个技巧。分别是理解指针或引用的含义、警惕指针丢失和内存泄漏、利用哨兵简化实现难度、重点留意边界条件处理，以及举例画图、辅助思考，还有多写多练。</p><p>我觉得，<strong>写链表代码是最考验逻辑思维能力的</strong>。因为，链表代码到处都是指针的操作、边界条件的处理，稍有不慎就容易产生Bug。链表代码写得好坏，可以看出一个人写代码是否够细心，考虑问题是否全面，思维是否缜密。所以，这也是很多面试官喜欢让人手写链表代码的原因。所以，这一节讲到的东西，你一定要自己写代码实现一下，才有效果。</p><h2>课后思考</h2><p>今天我们讲到用哨兵来简化编码实现，你是否还能够想到其他场景，利用哨兵可以大大地简化编码难度？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"07 | 链表（下）：如何轻松写出正确的链表代码？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/d4/87/d4a605ceae45de300ebb90df1865f087.mp3",
		"column_id":126,
		"id":41149,
		"site_source_url":"https://time.geekbang.org/column/article/41149"
	},
	{
		"article_content":"<p>今天我们来聊聊“链表（Linked list）”这个数据结构。学习链表有什么用呢？为了回答这个问题，我们先来讨论一个经典的链表应用场景，那就是LRU缓存淘汰算法。</p><p>缓存是一种提高数据读取性能的技术，在硬件设计、软件开发中都有着非常广泛的应用，比如常见的CPU缓存、数据库缓存、浏览器缓存等等。</p><p>缓存的大小有限，当缓存被用满时，哪些数据应该被清理出去，哪些数据应该被保留？这就需要缓存淘汰策略来决定。常见的策略有三种：先进先出策略FIFO（First In，First Out）、最少使用策略LFU（Least Frequently Used）、最近最少使用策略LRU（Least Recently Used）。</p><p>这些策略你不用死记，我打个比方你很容易就明白了。假如说，你买了很多本技术书，但有一天你发现，这些书太多了，太占书房空间了，你要做个大扫除，扔掉一些书籍。那这个时候，你会选择扔掉哪些书呢？对应一下，你的选择标准是不是和上面的三种策略神似呢？</p><p>好了，回到正题，我们今天的开篇问题就是：<strong><span class=\"orange\">如何用链表来实现LRU缓存淘汰策略呢？</span></strong> 带着这个问题，我们开始今天的内容吧！</p><h2>五花八门的链表结构</h2><p>相比数组，链表是一种稍微复杂一点的数据结构。对于初学者来说，掌握起来也要比数组稍难一些。这两个非常基础、非常常用的数据结构，我们常常将会放到一块儿来比较。所以我们先来看，这两者有什么区别。</p><!-- [[[read_end]]] --><p>我们先从<strong>底层的存储结构</strong>上来看一看。</p><p>为了直观地对比，我画了一张图。从图中我们看到，数组需要一块<strong>连续的内存空间</strong>来存储，对内存的要求比较高。如果我们申请一个100MB大小的数组，当内存中没有连续的、足够大的存储空间时，即便内存的剩余总可用空间大于100MB，仍然会申请失败。</p><p>而链表恰恰相反，它并不需要一块连续的内存空间，它通过“指针”将一组<strong>零散的内存块</strong>串联起来使用，所以如果我们申请的是100MB大小的链表，根本不会有问题。</p><p><img src=\"https://static001.geekbang.org/resource/image/d5/cd/d5d5bee4be28326ba3c28373808a62cd.jpg\" alt=\"\"></p><p>链表结构五花八门，今天我重点给你介绍三种最常见的链表结构，它们分别是：单链表、双向链表和循环链表。我们首先来看最简单、最常用的<strong>单链表</strong>。</p><p>我们刚刚讲到，链表通过指针将一组零散的内存块串联在一起。其中，我们把内存块称为链表的“<strong>结点</strong>”。为了将所有的结点串起来，每个链表的结点除了存储数据之外，还需要记录链上的下一个结点的地址。如图所示，我们把这个记录下个结点地址的指针叫作<strong>后继指针next</strong>。</p><p><img src=\"https://static001.geekbang.org/resource/image/b9/eb/b93e7ade9bb927baad1348d9a806ddeb.jpg\" alt=\"\"></p><p>从我画的单链表图中，你应该可以发现，其中有两个结点是比较特殊的，它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作<strong>头结点</strong>，把最后一个结点叫作<strong>尾结点</strong>。其中，头结点用来记录链表的基地址。有了它，我们就可以遍历得到整条链表。而尾结点特殊的地方是：指针不是指向下一个结点，而是指向一个<strong>空地址NULL</strong>，表示这是链表上最后一个结点。</p><p>与数组一样，链表也支持数据的查找、插入和删除操作。</p><p>我们知道，在进行数组的插入、删除操作时，为了保持内存数据的连续性，需要做大量的数据搬移，所以时间复杂度是O(n)。而在链表中插入或者删除一个数据，我们并不需要为了保持内存的连续性而搬移结点，因为链表的存储空间本身就不是连续的。所以，在链表中插入和删除一个数据是非常快速的。</p><p>为了方便你理解，我画了一张图，从图中我们可以看出，针对链表的插入和删除操作，我们只需要考虑相邻结点的指针改变，所以对应的时间复杂度是O(1)。</p><p><img src=\"https://static001.geekbang.org/resource/image/45/17/452e943788bdeea462d364389bd08a17.jpg\" alt=\"\"></p><p>但是，有利就有弊。链表要想随机访问第k个元素，就没有数组那么高效了。因为链表中的数据并非连续存储的，所以无法像数组那样，根据首地址和下标，通过寻址公式就能直接计算出对应的内存地址，而是需要根据指针一个结点一个结点地依次遍历，直到找到相应的结点。</p><p>你可以把链表想象成一个队伍，队伍中的每个人都只知道自己后面的人是谁，所以当我们希望知道排在第k位的人是谁的时候，我们就需要从第一个人开始，一个一个地往下数。所以，链表随机访问的性能没有数组好，需要O(n)的时间复杂度。</p><p>好了，单链表我们就简单介绍完了，接着来看另外两个复杂的升级版，<strong>循环链表</strong>和<strong>双向链表</strong>。</p><p><strong>循环链表是一种特殊的单链表</strong>。实际上，循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道，单链表的尾结点指针指向空地址，表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中，你应该可以看出来，它像一个环一样首尾相连，所以叫作“循环”链表。</p><p><img src=\"https://static001.geekbang.org/resource/image/86/55/86cb7dc331ea958b0a108b911f38d155.jpg\" alt=\"\"></p><p>和单链表相比，<strong>循环链表</strong>的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时，就特别适合采用循环链表。比如著名的<a href=\"https://zh.wikipedia.org/wiki/%E7%BA%A6%E7%91%9F%E5%A4%AB%E6%96%AF%E9%97%AE%E9%A2%98\">约瑟夫问题</a>。尽管用单链表也可以实现，但是用循环链表实现的话，代码就会简洁很多。</p><p>单链表和循环链表是不是都不难？接下来我们再来看一个稍微复杂的，在实际的软件开发中，也更加常用的链表结构：<strong>双向链表</strong>。</p><p>单向链表只有一个方向，结点只有一个后继指针next指向后面的结点。而双向链表，顾名思义，它支持两个方向，每个结点不止有一个后继指针next指向后面的结点，还有一个前驱指针prev指向前面的结点。</p><p><img src=\"https://static001.geekbang.org/resource/image/cb/0b/cbc8ab20276e2f9312030c313a9ef70b.jpg\" alt=\"\"></p><p>从我画的图中可以看出来，双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以，如果存储同样多的数据，双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间，但可以支持双向遍历，这样也带来了双向链表操作的灵活性。那相比单链表，双向链表适合解决哪种问题呢？</p><p>从结构上来看，双向链表可以支持O(1)时间复杂度的情况下找到前驱结点，正是这样的特点，也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。</p><p>你可能会说，我刚讲到单链表的插入、删除操作的时间复杂度已经是O(1)了，双向链表还能再怎么高效呢？别着急，刚刚的分析比较偏理论，很多数据结构和算法书籍中都会这么讲，但是这种说法实际上是不准确的，或者说是有先决条件的。我再来带你分析一下链表的两个操作。</p><p>我们先来看<strong>删除操作</strong>。</p><p>在实际的软件开发中，从链表中删除一个数据无外乎这两种情况：</p><ul>\n<li>\n<p>删除结点中“值等于某个给定值”的结点；</p>\n</li>\n<li>\n<p>删除给定指针指向的结点。</p>\n</li>\n</ul><p>对于第一种情况，不管是单链表还是双向链表，为了查找到值等于给定值的结点，都需要从头结点开始一个一个依次遍历对比，直到找到值等于给定值的结点，然后再通过我前面讲的指针操作将其删除。</p><p>尽管单纯的删除操作时间复杂度是O(1)，但遍历查找的时间是主要的耗时点，对应的时间复杂度为O(n)。根据时间复杂度分析中的加法法则，删除值等于给定值的结点对应的链表操作的总时间复杂度为O(n)。</p><p>对于第二种情况，我们已经找到了要删除的结点，但是删除某个结点q需要知道其前驱结点，而单链表并不支持直接获取前驱结点，所以，为了找到前驱结点，我们还是要从头结点开始遍历链表，直到p-&gt;next=q，说明p是q的前驱结点。</p><p>但是对于双向链表来说，这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针，不需要像单链表那样遍历。所以，针对第二种情况，单链表删除操作需要O(n)的时间复杂度，而双向链表只需要在O(1)的时间复杂度内就搞定了！</p><p>同理，如果我们希望在链表的某个指定结点前面插入一个结点，双向链表比单链表有很大的优势。双向链表可以在O(1)时间复杂度搞定，而单向链表需要O(n)的时间复杂度。你可以参照我刚刚讲过的删除操作自己分析一下。</p><p>除了插入、删除操作有优势之外，对于一个有序链表，双向链表的按值查询的效率也要比单链表高一些。因为，我们可以记录上次查找的位置p，每次查询时，根据要查找的值与p的大小关系，决定是往前还是往后查找，所以平均只需要查找一半的数据。</p><p>现在，你有没有觉得双向链表要比单链表更加高效呢？这就是为什么在实际的软件开发中，双向链表尽管比较费内存，但还是比单链表的应用更加广泛的原因。如果你熟悉Java语言，你肯定用过LinkedHashMap这个容器。如果你深入研究LinkedHashMap的实现原理，就会发现其中就用到了双向链表这种数据结构。</p><p>实际上，这里有一个更加重要的知识点需要你掌握，那就是<strong>用空间换时间</strong>的设计思想。当内存空间充足的时候，如果我们更加追求代码的执行速度，我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反，如果内存比较紧缺，比如代码跑在手机或者单片机上，这个时候，就要反过来用时间换空间的设计思路。</p><p>还是开篇缓存的例子。缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储在硬盘上，会比较节省内存，但每次查找数据都要询问一次硬盘，会比较慢。但如果我们通过缓存技术，事先将数据加载在内存中，虽然会比较耗费内存空间，但是每次数据查询的速度就大大提高了。</p><p>所以我总结一下，对于执行较慢的程序，可以通过消耗更多的内存（空间换时间）来进行优化；而消耗过多内存的程序，可以通过消耗更多的时间（时间换空间）来降低内存的消耗。你还能想到其他时间换空间或者空间换时间的例子吗？</p><p>了解了循环链表和双向链表，如果把这两种链表整合在一起就是一个新的版本：<strong>双向循环链表</strong>。我想不用我多讲，你应该知道双向循环链表长什么样子了吧？你可以自己试着在纸上画一画。</p><p><img src=\"https://static001.geekbang.org/resource/image/d1/91/d1665043b283ecdf79b157cfc9e5ed91.jpg\" alt=\"\"></p><h2>链表VS数组性能大比拼</h2><p>通过前面内容的学习，你应该已经知道，数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别，它们插入、删除、随机访问操作的时间复杂度正好相反。</p><p><img src=\"https://static001.geekbang.org/resource/image/4f/68/4f63e92598ec2551069a0eef69db7168.jpg\" alt=\"\"></p><p>不过，数组和链表的对比，并不能局限于时间复杂度。而且，在实际的软件开发中，不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。</p><p>数组简单易用，在实现上使用的是连续的内存空间，可以借助CPU的缓存机制，预读数组中的数据，所以访问效率更高。而链表在内存中并不是连续存储，所以对CPU缓存不友好，没办法有效预读。</p><p>数组的缺点是大小固定，一经声明就要占用整块连续内存空间。如果声明的数组过大，系统可能没有足够的连续内存空间分配给它，导致“内存不足（out of memory）”。如果声明的数组过小，则可能出现不够用的情况。这时只能再申请一个更大的内存空间，把原数组拷贝进去，非常费时。链表本身没有大小的限制，天然地支持动态扩容，我觉得这也是它与数组最大的区别。</p><p>你可能会说，我们Java中的ArrayList容器，也可以支持动态扩容啊？我们上一节课讲过，当我们往支持动态扩容的数组中插入一个数据时，如果数组中没有空闲空间了，就会申请一个更大的空间，将数据拷贝过去，而数据拷贝的操作是非常耗时的。</p><p>我举一个稍微极端的例子。如果我们用ArrayList存储了了1GB大小的数据，这个时候已经没有空闲空间了，当我们再插入数据的时候，ArrayList会申请一个1.5GB大小的存储空间，并且把原来那1GB的数据拷贝到新申请的空间上。听起来是不是就很耗时？</p><p>除此之外，如果你的代码对内存的使用非常苛刻，那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针，所以内存消耗会翻倍。而且，对链表进行频繁的插入、删除操作，还会导致频繁的内存申请和释放，容易造成内存碎片，如果是Java语言，就有可能会导致频繁的GC（Garbage Collection，垃圾回收）。</p><p>所以，在我们实际的开发中，针对不同类型的项目，要根据具体情况，权衡究竟是选择数组还是链表。</p><h2>解答开篇</h2><p>好了，关于链表的知识我们就讲完了。我们现在回过头来看下开篇留给你的思考题。如何基于链表实现LRU缓存淘汰算法？</p><p>我的思路是这样的：我们维护一个有序单链表，越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时，我们从链表头开始顺序遍历链表。</p><p>1.如果此数据之前已经被缓存在链表中了，我们遍历得到这个数据对应的结点，并将其从原来的位置删除，然后再插入到链表的头部。</p><p>2.如果此数据没有在缓存链表中，又可以分为两种情况：</p><ul>\n<li>\n<p>如果此时缓存未满，则将此结点直接插入到链表的头部；</p>\n</li>\n<li>\n<p>如果此时缓存已满，则链表尾结点删除，将新的数据结点插入链表的头部。</p>\n</li>\n</ul><p>这样我们就用链表实现了一个LRU缓存，是不是很简单？</p><p>现在我们来看下m缓存访问的时间复杂度是多少。因为不管缓存有没有满，我们都需要遍历一遍链表，所以这种基于链表的实现思路，缓存访问的时间复杂度为O(n)。</p><p>实际上，我们可以继续优化这个实现思路，比如引入<strong>散列表</strong>（Hash table）来记录每个数据的位置，将缓存访问的时间复杂度降到O(1)。因为要涉及我们还没有讲到的数据结构，所以这个优化方案，我现在就不详细说了，等讲到散列表的时候，我会再拿出来讲。</p><p>除了基于链表的实现思路，实际上还可以用数组来实现LRU缓存淘汰策略。如何利用数组实现LRU缓存淘汰策略呢？我把这个问题留给你思考。</p><h2>内容小结</h2><p>今天我们讲了一种跟数组“相反”的数据结构，链表。它跟数组一样，也是非常基础、非常常用的数据结构。不过链表要比数组稍微复杂，从普通的单链表衍生出来好几种链表结构，比如双向链表、循环链表、双向循环链表。</p><p>和数组相比，链表更适合插入、删除操作频繁的场景，查询的时间复杂度较高。不过，在具体软件开发中，要对数组和链表的各种性能进行对比，综合来选择使用两者中的哪一个。</p><h2>课后思考</h2><p>如何判断一个字符串是否是回文字符串的问题，我想你应该听过，我们今天的思题目就是基于这个问题的改造版本。如果字符串是通过单链表来存储的，那该如何来判断是一个回文串呢？你有什么好的解决思路呢？相应的时间空间复杂度又是多少呢？</p><p>欢迎留言和我分享，我会第一时间给你反馈。</p><hr></hr><p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p><p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>",
		"article_title":"06 | 链表（上）：如何实现LRU缓存淘汰算法?",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/bc/3a/bc1cd295e7b4b17caf28f0bcc552b93a.mp3",
		"column_id":126,
		"id":41013,
		"site_source_url":"https://time.geekbang.org/column/article/41013"
	},
	{
		"article_content":"<p>提到数组，我想你肯定不陌生，甚至还会自信地说，它很简单啊。</p><p>是的，在每一种编程语言中，基本都会有数组这种数据类型。不过，它不仅仅是一种编程语言中的数据类型，还是一种最基础的数据结构。尽管数组看起来非常基础、简单，但是我估计很多人都并没有理解这个基础数据结构的精髓。</p><p>在大部分编程语言中，数组都是从0开始编号的，但你是否下意识地想过，<strong><span class=\"orange\">为什么数组要从0开始编号，而不是从1开始呢？</span></strong> 从1开始不是更符合人类的思维习惯吗？</p><p>你可以带着这个问题来学习接下来的内容。</p><h2>如何实现随机访问？</h2><p>什么是数组？我估计你心中已经有了答案。不过，我还是想用专业的话来给你做下解释。<strong>数组（Array）是一种线性表数据结构。它用一组连续的内存空间，来存储一组具有相同类型的数据。</strong></p><p>这个定义里有几个关键词，理解了这几个关键词，我想你就能彻底掌握数组的概念了。下面就从我的角度分别给你“点拨”一下。</p><p>第一是<strong>线性表</strong>（Linear List）。顾名思义，线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组，链表、队列、栈等也是线性表结构。</p><p><img src=\"https://static001.geekbang.org/resource/image/b6/77/b6b71ec46935130dff5c4b62cf273477.jpg\" alt=\"\"></p><p>而与它相对立的概念是<strong>非线性表</strong>，比如二叉树、堆、图等。之所以叫非线性，是因为，在非线性表中，数据之间并不是简单的前后关系。</p><!-- [[[read_end]]] --><p><img src=\"https://static001.geekbang.org/resource/image/6e/69/6ebf42641b5f98f912d36f6bf86f6569.jpg\" alt=\"\"></p><p>第二个是<strong>连续的内存空间和相同类型的数据</strong>。正是因为这两个限制，它才有了一个堪称“杀手锏”的特性：“随机访问”。但有利就有弊，这两个限制也让数组的很多操作变得非常低效，比如要想在数组中删除、插入一个数据，为了保证连续性，就需要做大量的数据搬移工作。</p><p>说到数据的访问，那你知道数组是如何实现根据下标随机访问数组元素的吗？</p><p>我们拿一个长度为10的int类型的数组int[] a = new int[10]来举例。在我画的这个图中，计算机给数组a[10]，分配了一块连续内存空间1000～1039，其中，内存块的首地址为base_address = 1000。</p><p><img src=\"https://static001.geekbang.org/resource/image/98/c4/98df8e702b14096e7ee4a5141260cdc4.jpg\" alt=\"\"></p><p>我们知道，计算机会给每个内存单元分配一个地址，计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时，它会首先通过下面的寻址公式，计算出该元素存储的内存地址：</p><pre><code>a[i]_address = base_address + i * data_type_size\n</code></pre><p>其中data_type_size表示数组中每个元素的大小。我们举的这个例子里，数组中存储的是int类型数据，所以data_type_size就为4个字节。这个公式非常简单，我就不多做解释了。</p><p>这里我要特别纠正一个“错误”。我在面试的时候，常常会问数组和链表的区别，很多人都回答说，“链表适合插入、删除，时间复杂度O(1)；数组适合查找，查找时间复杂度为O(1)”。</p><p>实际上，这种表述是不准确的。数组是适合查找操作，但是查找的时间复杂度并不为O(1)。即便是排好序的数组，你用二分查找，时间复杂度也是O(logn)。所以，正确的表述应该是，数组支持随机访问，根据下标随机访问的时间复杂度为O(1)。</p><h2>低效的“插入”和“删除”</h2><p>前面概念部分我们提到，数组为了保持内存数据的连续性，会导致插入、删除这两个操作比较低效。现在我们就来详细说一下，究竟为什么会导致低效？又有哪些改进方法呢？</p><p>我们先来看<strong>插入操作</strong>。</p><p>假设数组的长度为n，现在，如果我们需要将一个数据插入到数组中的第k个位置。为了把第k个位置腾出来，给新来的数据，我们需要将第k～n这部分的元素都顺序地往后挪一位。那插入操作的时间复杂度是多少呢？你可以自己先试着分析一下。</p><p>如果在数组的末尾插入元素，那就不需要移动数据了，这时的时间复杂度为O(1)。但如果在数组的开头插入元素，那所有的数据都需要依次往后移动一位，所以最坏时间复杂度是O(n)。 因为我们在每个位置插入元素的概率是一样的，所以平均情况时间复杂度为(1+2+…n)/n=O(n)。</p><p>如果数组中的数据是有序的，我们在某个位置插入一个新的元素时，就必须按照刚才的方法搬移k之后的数据。但是，如果数组中存储的数据并没有任何规律，数组只是被当作一个存储数据的集合。在这种情况下，如果要将某个数组插入到第k个位置，为了避免大规模的数据搬移，我们还有一个简单的办法就是，直接将第k位的数据搬移到数组元素的最后，把新的元素直接放入第k个位置。</p><p>为了更好地理解，我们举一个例子。假设数组a[10]中存储了如下5个元素：a，b，c，d，e。</p><p>我们现在需要将元素x插入到第3个位置。我们只需要将c放入到a[5]，将a[2]赋值为x即可。最后，数组中的元素如下： a，b，x，d，e，c。</p><p><img src=\"https://static001.geekbang.org/resource/image/3f/dc/3f70b4ad9069ec568a2caaddc231b7dc.jpg\" alt=\"\"></p><p>利用这种处理技巧，在特定场景下，在第k个位置插入一个元素的时间复杂度就会降为O(1)。这个处理思想在快排中也会用到，我会在排序那一节具体来讲，这里就说到这儿。</p><p>我们再来看<strong>删除操作</strong>。</p><p>跟插入数据类似，如果我们要删除第k个位置的数据，为了内存的连续性，也需要搬移数据，不然中间就会出现空洞，内存就不连续了。</p><p>和插入类似，如果删除数组末尾的数据，则最好情况时间复杂度为O(1)；如果删除开头的数据，则最坏情况时间复杂度为O(n)；平均情况时间复杂度也为O(n)。</p><p>实际上，在某些特殊场景下，我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行，删除的效率是不是会提高很多呢？</p><p>我们继续来看例子。数组a[10]中存储了8个元素：a，b，c，d，e，f，g，h。现在，我们要依次删除a，b，c三个元素。</p><p><img src=\"https://static001.geekbang.org/resource/image/b6/e5/b69b8c5dbf6248649ddab7d3e7cfd7e5.jpg\" alt=\"\"></p><p>为了避免d，e，f，g，h这几个数据会被搬移三次，我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据，只是记录数据已经被删除。当数组没有更多空间存储数据时，我们再触发执行一次真正的删除操作，这样就大大减少了删除操作导致的数据搬移。</p><p>如果你了解JVM，你会发现，这不就是JVM标记清除垃圾回收算法的核心思想吗？没错，数据结构和算法的魅力就在于此，<strong><span class=\"orange\">很多时候我们并不是要去死记硬背某个数据结构或者算法，而是要学习它背后的思想和处理技巧，这些东西才是最有价值的</span></strong>。如果你细心留意，不管是在软件开发还是架构设计中，总能找到某些算法和数据结构的影子。</p><h2>警惕数组的访问越界问题</h2><p>了解了数组的几个基本操作后，我们来聊聊数组访问越界的问题。</p><p>首先，我请你来分析一下这段C语言代码的运行结果：</p><pre><code>int main(int argc, char* argv[]){\n    int i = 0;\n    int arr[3] = {0};\n    for(; i&lt;=3; i++){\n        arr[i] = 0;\n        printf(&quot;hello world\\n&quot;);\n    }\n    return 0;\n}\n</code></pre><p>你发现问题了吗？这段代码的运行结果并非是打印三行“hello word”，而是会无限打印“hello world”，这是为什么呢？</p><p>因为，数组大小为3，a[0]，a[1]，a[2]，而我们的代码因为书写错误，导致for循环的结束条件错写为了i&lt;=3而非i&lt;3，所以当i=3时，数组a[3]访问越界。</p><p>我们知道，在C语言中，只要不是访问受限的内存，所有的内存空间都是可以自由访问的。根据我们前面讲的数组寻址公式，a[3]也会被定位到某块不属于数组的内存地址上，而这个地址正好是存储变量i的内存地址，那么a[3]=0就相当于i=0，所以就会导致代码无限循环。</p><p>数组越界在C语言中是一种未决行为，并没有规定数组访问越界时编译器应该如何处理。因为，访问数组的本质就是访问一段连续内存，只要数组通过偏移计算得到的内存地址是可用的，那么程序就可能不会报任何错误。</p><p>这种情况下，一般都会出现莫名其妙的逻辑错误，就像我们刚刚举的那个例子，debug的难度非常的大。而且，很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞，来攻击系统，所以写代码的时候一定要警惕数组越界。</p><p>但并非所有的语言都像C一样，把数组越界检查的工作丢给程序员来做，像Java本身就会做越界检查，比如下面这几行Java代码，就会抛出java.lang.ArrayIndexOutOfBoundsException。</p><pre><code>int[] a = new int[3];\na[3] = 10;\n</code></pre><h2>容器能否完全替代数组？</h2><p>针对数组类型，很多语言都提供了容器类，比如Java中的ArrayList、C++ STL中的vector。在项目开发中，什么时候适合用数组，什么时候适合用容器呢？</p><p>这里我拿Java语言来举例。如果你是Java工程师，几乎天天都在用ArrayList，对它应该非常熟悉。那它与数组相比，到底有哪些优势呢？</p><p>我个人觉得，ArrayList最大的优势就是<strong>可以将很多数组操作的细节封装起来</strong>。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外，它还有一个优势，就是<strong>支持动态扩容</strong>。</p><p>数组本身在定义的时候需要预先指定大小，因为需要分配连续的内存空间。如果我们申请了大小为10的数组，当第11个数据需要存储到数组中时，我们就需要重新分配一块更大的空间，将原来的数据复制过去，然后再将新的数据插入。</p><p>如果使用ArrayList，我们就完全不需要关心底层的扩容逻辑，ArrayList已经帮我们实现好了。每次存储空间不够的时候，它都会将空间自动扩容为1.5倍大小。</p><p>不过，这里需要注意一点，因为扩容操作涉及内存申请和数据搬移，是比较耗时的。所以，如果事先能确定需要存储的数据大小，最好<strong>在创建ArrayList的时候事先指定数据大小</strong>。</p><p>比如我们要从数据库中取出10000条数据放入ArrayList。我们看下面这几行代码，你会发现，相比之下，事先指定数据大小可以省掉很多次内存申请和数据搬移操作。</p><pre><code>ArrayList&lt;User&gt; users = new ArrayList(10000);\nfor (int i = 0; i &lt; 10000; ++i) {\n  users.add(xxx);\n}\n</code></pre><p>作为高级语言编程者，是不是数组就无用武之地了呢？当然不是，有些时候，用数组会更合适些，我总结了几点自己的经验。</p><p>1.Java ArrayList无法存储基本类型，比如int、long，需要封装为Integer、Long类，而Autoboxing、Unboxing则有一定的性能消耗，所以如果特别关注性能，或者希望使用基本类型，就可以选用数组。</p><p>2.如果数据大小事先已知，并且对数据的操作非常简单，用不到ArrayList提供的大部分方法，也可以直接使用数组。</p><p>3.还有一个是我个人的喜好，当要表示多维数组时，用数组往往会更加直观。比如Object[][] array；而用容器的话则需要这样定义：ArrayList&lt;ArrayList<object> &gt; array。<p></p>\n<p>我总结一下，对于业务开发，直接使用容器就足够了，省时省力。毕竟损耗一丢丢性能，完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发，比如开发网络框架，性能的优化需要做到极致，这个时候数组就会优于容器，成为首选。</p>\n<h2>解答开篇</h2>\n<p>现在我们来思考开篇的问题：为什么大多数编程语言中，数组要从0开始编号，而不是从1开始呢？</p>\n<p>从数组存储的内存模型上来看，“下标”最确切的定义应该是“偏移（offset）”。前面也讲到，如果用a来表示数组的首地址，a[0]就是偏移为0的位置，也就是首地址，a[k]就表示偏移k个type_size的位置，所以计算a[k]的内存地址只需要用这个公式：</p>\n<pre><code>a[k]_address = base_address + k * type_size\n</code></pre>\n<p>但是，如果数组从1开始计数，那我们计算数组元素a[k]的内存地址就会变为：</p>\n<pre><code>a[k]_address = base_address + (k-1)*type_size\n</code></pre>\n<p>对比两个公式，我们不难发现，从1开始编号，每次随机访问数组元素都多了一次减法运算，对于CPU来说，就是多了一次减法指令。</p>\n<p>数组作为非常基础的数据结构，通过下标随机访问数组元素又是其非常基础的编程操作，效率的优化就要尽可能做到极致。所以为了减少一次减法操作，数组选择了从0开始编号，而不是从1开始。</p>\n<p>不过我认为，上面解释得再多其实都算不上压倒性的证明，说数组起始编号非0开始不可。所以我觉得最主要的原因可能是历史原因。</p>\n<p>C语言设计者用0开始计数数组下标，之后的Java、JavaScript等高级语言都效仿了C语言，或者说，为了在一定程度上减少C语言程序员学习Java的学习成本，因此继续沿用了从0开始计数的习惯。实际上，很多语言中数组也并不是从0开始计数的，比如Matlab。甚至还有一些语言支持负数下标，比如Python。</p>\n<h2>内容小结</h2>\n<p>我们今天学习了数组。它可以说是最基础、最简单的数据结构了。数组用一块连续的内存空间，来存储相同类型的一组数据，最大的特点就是支持随机访问，但插入、删除操作也因此变得比较低效，平均情况时间复杂度为O(n)。在平时的业务开发中，我们可以直接使用编程语言提供的容器类，但是，如果是特别底层的开发，直接使用数组可能会更合适。</p>\n<h2>课后思考</h2>\n<ol>\n<li>\n<p>前面我基于数组的原理引出JVM的标记清除垃圾回收算法的核心理念。我不知道你是否使用Java语言，理解JVM，如果你熟悉，可以在评论区回顾下你理解的标记清除垃圾回收算法。</p>\n</li>\n<li>\n<p>前面我们讲到一维数组的内存寻址公式，那你可以思考一下，类比一下，二维数组的内存寻址公式是怎样的呢？</p>\n</li>\n</ol>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<hr>\n<p><span class=\"orange\">我已将本节内容相关的详细代码更新到GitHub，<a href=\"https://github.com/wangzheng0822/algo\">戳此</a>即可查看。</span></p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\"></p>\n</object></p>",
		"article_title":"05 | 数组：为什么很多编程语言中数组都从0开始编号？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/5f/47/5fc6530ff4c95d755810e96393733847.mp3",
		"column_id":126,
		"id":40961,
		"site_source_url":"https://time.geekbang.org/column/article/40961"
	},
	{
		"article_content":"<p>你好，我是王争。欢迎来到不定期更新的周末福利时间。</p>\n<p>专栏已经上线两周了，看到这么多人在留言区写下自己的疑惑或者观点，我特别开心。在留言里，很多同学让我推荐一些学习数据结构与算法的书籍。因此我特意跟编辑商量了，给你一个周末福利。所以这一期呢，我们就来聊一聊数据结构和算法学习过程中有哪些必读书籍。</p>\n<p>有的同学还在读大学，代码还没写过几行；有的同学已经工作数十年，这之间的差别还是挺大的。而不同基础的人，适宜看的书是完全不一样的。因此，<strong>针对不同层次、不同语言的同学，我分别推荐了不同的书</strong>。希望每个同学，都能找到适合自己的学习资料，都能在现有水平上有所提高。</p>\n<h2>针对入门的趣味书</h2>\n<p>入门的同学，我建议你不要过度追求上去就看经典书。像《算法导论》《算法》这些书，虽然比较经典、比较权威，但是非常厚。初学就去啃这些书肯定会很费劲。而一旦啃不下来，挫败感就会很强。所以，入门的同学，我建议你找一些比较容易看的书来看，比如《大话数据结构》和《算法图解》。<strong><span class=\"orange\">不要太在意书写得深浅，重要的是能不能坚持看完。</span></strong></p>\n<p>《<strong>大话数据结构</strong>》 这本书最大的特点是，它把理论讲得很有趣，不枯燥。而且每个数据结构和算法，作者都结合生活中的例子进行了讲解， 能让你有非常直观的感受。虽然这本书有400多页，但是花两天时间读完，应该是没问题的。如果你之前完全不懂数据结构和算法，可以先从这本书看起。</p><!-- [[[read_end]]] -->\n<p>《<strong>算法图解</strong>》 跟《大话数据结构》走的是同样的路线，就像这本书副标题写的那样，“像小说一样有趣的算法入门书”，主打“图解”，通俗易懂。它只有不到200页，所以内容比较少。作为入门，看看这本书，能让你对数据结构和算法有个大概的认识。</p>\n<p>这些入门书共同的问题是，缺少细节，不够系统，也不够严谨。所以，如果你想要系统地学数据结构和算法，看这两本书肯定是不够的。</p>\n<h2>针对特定编程语言的教科书</h2>\n<p>讲数据结构和算法，肯定会跟代码实现挂钩。所以，很多人就很关心，某某书籍是用什么语言实现的，是不是自己熟悉的语言。市面大部分数据结构和算法书籍都是用C、C++、Java语言实现的，还有些是用伪代码。而使用Python、Go、PHP、JavaScript、Objective-C这些编程语言实现的就更少了。</p>\n<p>我这里推荐《数据结构和算法分析》。国内外很多大学都拿这本书当作教材。这本书非常系统、全面、严谨，而且又不是特别难，适合对数据结构和算法有些了解，并且掌握了至少一门编程语言的同学。而且，这个作者也很用心。他用了三种语言，写了三个版本，分别是：《<strong>数据结构与算法分析 ：C语言描述</strong>》《<strong>数据结构与算法分析：C++描述</strong>》《<strong>数据结构与算法分析：Java语言描述</strong>》。</p>\n<p>如果你熟悉的是Python或者JavaScript，可以参考《<strong>数据结构与算法JavaScript描述</strong>》《<strong>数据结构与算法：Python语言描述</strong>》 。至于其他语言的算法书籍，确实比较少。如果你有推荐，可以在留言区补充一下。</p>\n<h2>面试必刷的宝典</h2>\n<p>算法对面试很重要，很多人也很关心。我这里推荐几本有益于面试的书籍，分别是：《剑指offer》《编程珠玑》《编程之美》。</p>\n<p>从《<strong>剑指offer</strong>》这本书的名字就可以看出，作者的写作目的非常明确，就是为了面试。这本书几乎包含了所有常见的、经典的面试题。如果能搞懂这本书里的内容，应付一般公司的面试应该不成问题。</p>\n<p>《<strong>编程珠玑</strong>》这本书的豆瓣评分非常高，有9分。这本书最大的特色就是讲了很多针对海量数据的处理技巧。这个可能是其他算法书籍很少涉及的。面试的时候，海量数据处理的问题也是经常会问的，特别是校招面试。不管是开拓眼界，还是应付面试，这本书都很值得一看。</p>\n<p>《<strong>编程之美</strong>》这本书有多位作者，其中绝大部分是微软的工程师，所以书的质量很有保证。不过，这里面的算法题目稍微有点难，也不是很系统，这也是我把它归到面试这一部分的原因。如果你有一定基础，也喜欢钻研些算法问题，或者要面试Google、Facebook这样的公司，可以拿这本书里的题，先来自测一下。</p>\n<h2>经典大部头</h2>\n<p>很多人一提到算法书就会搬出《算法导论》和《算法》。这两本确实非常经典，但是都太厚了，看起来比较费劲，我估计很少有人能坚持全部看下来。如果你想更加深入地学一学数据结构和算法，我还是强烈建议你看看。</p>\n<p>我个人觉得，《<strong>算法导论</strong>》这本书的章节安排不是循序渐进的，里面充斥着各种算法的正确性、复杂度的证明、推导，数学公式比较多，一般人看起来会比较吃力。所以，作为入门书籍，并不是很推荐。</p>\n<p>《<strong>算法</strong>》这本书也是一本经典大部头，不过它比起《算法导论》来要友好很多，更容易看懂，更适合初学者入门。但是这本书的缺点也很明显，就是内容不够全面，比如动态规划这么重要的知识点，这本书就没有讲。对于数据结构的东西，它讲的也不多，基本就是偏重讲算法。</p>\n<h2>殿堂级经典</h2>\n<p>说到殿堂级经典书，如果《<strong>计算机程序设计艺术</strong>》称第二，我想没人敢称第一。这本书包括很多卷。说实话，我也只看过比较简单的几卷，比如《基本算法》《排序和查找》。</p>\n<p>这套书的深度、广度、系统性、全面性是其他所有数据结构和算法书籍都无法相比的。但是，如果你对算法和数据结构不是特别感兴趣，没有很好的数学、算法、计算机基础，想要把这套书读完、读懂是比较难的。你可以把它当作你算法学习的终极挑战。</p>\n<h2>闲暇阅读</h2>\n<p>算法无处不在。我这里再推荐几本适合闲暇时间阅读的书：《<strong>算法帝国</strong>》《<strong>数学之美</strong>》《<strong>算法之美</strong>》。</p>\n<p>这些书共同的特点是，都列举了大量的例子，非常通俗易懂。夸张点说，像《算法帝国》，文科生都能读懂。当你看这些书的时候，你常常会深深感受到算法的力量，被算法的优美之处折服。即便不是从事IT工作的，看完这几本书也可以开拓眼界。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/1e/b8/1e306ffd0d56facbda45f413bc27a4b8.jpg\" alt=\"\" /></p>\n<p>书籍差不多就是这些。除此之外，留言区很多人问到算法的实现语言。我这里也解释一下。因为我现在比较常用的编程语言是Java。所以，在专栏里，特别简单的、不涉及高级语法的，我会用Java或者C、C++来实现。稍微复杂的，为了让你能看懂，我会用伪代码。所以你完全不用担心语言的问题。</p>\n<p>每节课中有需要代码实现的数据结构和算法，我都另外用Java语言实现一遍，然后放到Github上，供你参考。Github的地址我放在这里，你可以收藏一下：<a href=\"https://github.com/wangzheng0822/algo\">https://github.com/wangzheng0822/algo</a>。</p>\n<p>至于其他语言的同学，比如C、C++、Python、Go、PHP、JavaScript、Objective-C等，我想了一个crowd sourcing的方法。</p>\n<p>我希望基础较好的同学，参照我的Java实现，用你熟悉的编程语言再实现一遍，并且将代码留言给我。如果你写得正确，我会将你的代码上传到Github上，分享给更多人。</p>\n<p>还有人问，我学完这个专栏，就可以拿下数据结构和算法吗？我想说的是，<strong><span class=\"orange\">每个人的基础、学习能力都不一样，掌握程度取决于你的努力程度</span></strong>。除了你之外，没有人能百分之百保证你能掌握什么知识。</p>\n<p>有的同学只是把每一节课听下来、看下来，就束之高阁，也不求甚解，那效果肯定会很差。而有些同学除了听、看之外，遇到不懂的会自己去查资料、看参考书籍，还会把我讲的数据结构和算法都认真地实现一遍，这样的学习效果自然就比只听一遍、看一遍要好很多。即便我已经尽我所能我这些知识讲得深入浅出，通俗易懂，但是学习依然还是要靠你自己啊。</p>\n<p>这种答疑的方式也会成为我们之后的固定动作，我会把留言里有价值的问题和反馈沉淀下来，希望对你的日常学习起到补充作用。<strong>如果你有什么看不懂、听不懂的地方，或者工作中有遇到算法问题、技术难题，欢迎写在留言区。</strong>（我发现留言区里卧虎藏龙啊，没事儿可以多扫扫留言区。）</p>\n<p>这次的周末福利时间就到这啦，我们下次见！</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"不定期的周末福利第一期 | 数据结构与算法学习书单",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/d9/30/d9d8c173f0be5c4bedc08b48161bd630.mp3",
		"column_id":126,
		"id":40681,
		"site_source_url":"https://time.geekbang.org/column/article/40681"
	},
	{
		"article_content":"<p>上一节，我们讲了复杂度的大O表示法和几个分析技巧，还举了一些常见复杂度分析的例子，比如O(1)、O(logn)、O(n)、O(nlogn)复杂度分析。掌握了这些内容，对于复杂度分析这个知识点，你已经可以到及格线了。但是，我想你肯定不会满足于此。</p>\n<p>今天我会继续给你讲四个复杂度分析方面的知识点，<strong><span class=\"orange\">最好情况时间复杂度</span></strong>（best case time complexity）、<strong><span class=\"orange\">最坏情况时间复杂度</span></strong>（worst case time complexity）、<strong><span class=\"orange\">平均情况时间复杂度</span></strong>（average case time complexity）、<strong><span class=\"orange\">均摊时间复杂度</span></strong>（amortized time complexity）。如果这几个概念你都能掌握，那对你来说，复杂度分析这部分内容就没什么大问题了。</p>\n<h2>最好、最坏情况时间复杂度</h2>\n<p>上一节我举的分析复杂度的例子都很简单，今天我们来看一个稍微复杂的。你可以用我上节教你的分析技巧，自己先试着分析一下这段代码的时间复杂度。</p>\n<pre><code>// n表示数组array的长度\nint find(int[] array, int n, int x) {\n  int i = 0;\n  int pos = -1;\n  for (; i &lt; n; ++i) {\n    if (array[i] == x) pos = i;\n  }\n  return pos;\n}\n</code></pre>\n<p>你应该可以看出来，这段代码要实现的功能是，在一个无序的数组（array）中，查找变量x出现的位置。如果没有找到，就返回-1。按照上节课讲的分析方法，这段代码的复杂度是O(n)，其中，n代表数组的长度。</p><!-- [[[read_end]]] -->\n<p>我们在数组中查找一个数据，并不需要每次都把整个数组都遍历一遍，因为有可能中途找到就可以提前结束循环了。但是，这段代码写得不够高效。我们可以这样优化一下这段查找代码。</p>\n<pre><code>// n表示数组array的长度\nint find(int[] array, int n, int x) {\n  int i = 0;\n  int pos = -1;\n  for (; i &lt; n; ++i) {\n    if (array[i] == x) {\n       pos = i;\n       break;\n    }\n  }\n  return pos;\n}\n</code></pre>\n<p>这个时候，问题就来了。我们优化完之后，这段代码的时间复杂度还是O(n)吗？很显然，咱们上一节讲的分析方法，解决不了这个问题。</p>\n<p>因为，要查找的变量x可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量x，那就不需要继续遍历剩下的n-1个数据了，那时间复杂度就是O(1)。但如果数组中不存在变量x，那我们就需要把整个数组都遍历一遍，时间复杂度就成了O(n)。所以，不同的情况下，这段代码的时间复杂度是不一样的。</p>\n<p>为了表示代码在不同情况下的不同时间复杂度，我们需要引入三个概念：最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度。</p>\n<p>顾名思义，<strong>最好情况时间复杂度就是，在最理想的情况下，执行这段代码的时间复杂度</strong>。就像我们刚刚讲到的，在最理想的情况下，要查找的变量x正好是数组的第一个元素，这个时候对应的时间复杂度就是最好情况时间复杂度。</p>\n<p>同理，<strong>最坏情况时间复杂度就是，在最糟糕的情况下，执行这段代码的时间复杂度</strong>。就像刚举的那个例子，如果数组中没有要查找的变量x，我们需要把整个数组都遍历一遍才行，所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度。</p>\n<h2>平均情况时间复杂度</h2>\n<p>我们都知道，最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度，发生的概率其实并不大。为了更好地表示平均情况下的复杂度，我们需要引入另一个概念：平均情况时间复杂度，后面我简称为平均时间复杂度。</p>\n<p>平均时间复杂度又该怎么分析呢？我还是借助刚才查找变量x的例子来给你解释。</p>\n<p>要查找的变量x在数组中的位置，有n+1种情况：<strong>在数组的0～n-1位置中</strong>和<strong>不在数组中</strong>。我们把每种情况下，查找需要遍历的元素个数累加起来，然后再除以n+1，就可以得到需要遍历的元素个数的平均值，即：</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/d8/2f/d889a358b8eccc5bbb90fc16e327a22f.jpg\" alt=\"\" /></p>\n<p>我们知道，时间复杂度的大O标记法中，可以省略掉系数、低阶、常量，所以，咱们把刚刚这个公式简化之后，得到的平均时间复杂度就是O(n)。</p>\n<p>这个结论虽然是正确的，但是计算过程稍微有点儿问题。究竟是什么问题呢？我们刚讲的这n+1种情况，出现的概率并不是一样的。我带你具体分析一下。（这里要稍微用到一点儿概率论的知识，不过非常简单，你不用担心。）</p>\n<p>我们知道，要查找的变量x，要么在数组里，要么就不在数组里。这两种情况对应的概率统计起来很麻烦，为了方便你理解，我们假设在数组中与不在数组中的概率都为1/2。另外，要查找的数据出现在0～n-1这n个位置的概率也是一样的，为1/n。所以，根据概率乘法法则，要查找的数据出现在0～n-1中任意位置的概率就是1/(2n)。</p>\n<p>因此，前面的推导过程中存在的最大问题就是，没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去，那平均时间复杂度的计算过程就变成了这样：</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/36/7f/36c0aabdac69032f8a43368f5e90c67f.jpg\" alt=\"\" /></p>\n<p>这个值就是概率论中的<strong>加权平均值</strong>，也叫作<strong>期望值</strong>，所以平均时间复杂度的全称应该叫<strong>加权平均时间复杂度</strong>或者<strong>期望时间复杂度</strong>。</p>\n<p>引入概率之后，前面那段代码的加权平均值为(3n+1)/4。用大O表示法来表示，去掉系数和常量，这段代码的加权平均时间复杂度仍然是O(n)。</p>\n<p>你可能会说，平均时间复杂度分析好复杂啊，还要涉及概率论的知识。实际上，在大多数情况下，我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。像我们上一节课举的那些例子那样，很多时候，我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下，时间复杂度有量级的差距，我们才会使用这三种复杂度表示法来区分。</p>\n<h2>均摊时间复杂度</h2>\n<p>到此为止，你应该已经掌握了算法复杂度分析的大部分内容了。下面我要给你讲一个更加高级的概念，均摊时间复杂度，以及它对应的分析方法，摊还分析（或者叫平摊分析）。</p>\n<p>均摊时间复杂度，听起来跟平均时间复杂度有点儿像。对于初学者来说，这两个概念确实非常容易弄混。我前面说了，大部分情况下，我们并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某些特殊情况下才会用到，而均摊时间复杂度应用的场景比它更加特殊、更加有限。</p>\n<p>老规矩，我还是借助一个具体的例子来帮助你理解。（当然，这个例子只是我为了方便讲解想出来的，实际上没人会这么写。）</p>\n<pre><code> // array表示一个长度为n的数组\n // 代码中的array.length就等于n\n int[] array = new int[n];\n int count = 0;\n \n void insert(int val) {\n    if (count == array.length) {\n       int sum = 0;\n       for (int i = 0; i &lt; array.length; ++i) {\n          sum = sum + array[i];\n       }\n       array[0] = sum;\n       count = 1;\n    }\n\n    array[count] = val;\n    ++count;\n }\n</code></pre>\n<p>我先来解释一下这段代码。这段代码实现了一个往数组中插入数据的功能。当数组满了之后，也就是代码中的count == array.length时，我们用for循环遍历数组求和，并清空数组，将求和之后的sum值放到数组的第一个位置，然后再将新的数据插入。但如果数组一开始就有空闲空间，则直接将数据插入数组。</p>\n<p>那这段代码的时间复杂度是多少呢？你可以先用我们刚讲到的三种时间复杂度的分析方法来分析一下。</p>\n<p>最理想的情况下，数组中有空闲空间，我们只需要将数据插入到数组下标为count的位置就可以了，所以最好情况时间复杂度为O(1)。最坏的情况下，数组中没有空闲空间了，我们需要先做一次数组的遍历求和，然后再将数据插入，所以最坏情况时间复杂度为O(n)。</p>\n<p>那平均时间复杂度是多少呢？答案是O(1)。我们还是可以通过前面讲的概率论的方法来分析。</p>\n<p>假设数组的长度是n，根据数据插入的位置的不同，我们可以分为n种情况，每种情况的时间复杂度是O(1)。除此之外，还有一种“额外”的情况，就是在数组没有空闲空间时插入一个数据，这个时候的时间复杂度是O(n)。而且，这n+1种情况发生的概率一样，都是1/(n+1)。所以，根据加权平均的计算方法，我们求得的平均时间复杂度就是：</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/6d/ed/6df62366a60336d9de3bc34f488d8bed.jpg\" alt=\"\" /></p>\n<p>至此为止，前面的最好、最坏、平均时间复杂度的计算，理解起来应该都没有问题。但是这个例子里的平均复杂度分析其实并不需要这么复杂，不需要引入概率论的知识。这是为什么呢？我们先来对比一下这个insert()的例子和前面那个find()的例子，你就会发现这两者有很大差别。</p>\n<p>首先，find()函数在极端情况下，复杂度才为O(1)。但insert()在大部分情况下，时间复杂度都为O(1)。只有个别情况下，复杂度才比较高，为O(n)。这是insert()<strong>第一个</strong>区别于find()的地方。</p>\n<p>我们再来看<strong>第二个</strong>不同的地方。对于insert()函数来说，O(1)时间复杂度的插入和O(n)时间复杂度的插入，出现的频率是非常有规律的，而且有一定的前后时序关系，一般都是一个O(n)插入之后，紧跟着n-1个O(1)的插入操作，循环往复。</p>\n<p>所以，针对这样一种特殊场景的复杂度分析，我们并不需要像之前讲平均复杂度分析方法那样，找出所有的输入情况及相应的发生概率，然后再计算加权平均值。</p>\n<p>针对这种特殊的场景，我们引入了一种更加简单的分析方法：<strong>摊还分析法</strong>，通过摊还分析得到的时间复杂度我们起了一个名字，叫<strong>均摊时间复杂度</strong>。</p>\n<p>那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢？</p>\n<p>我们还是继续看在数组中插入数据的这个例子。每一次O(n)的插入操作，都会跟着n-1次O(1)的插入操作，所以把耗时多的那次操作均摊到接下来的n-1次耗时少的操作上，均摊下来，这一组连续的操作的均摊时间复杂度就是O(1)。这就是均摊分析的大致思路。你都理解了吗？</p>\n<p>均摊时间复杂度和摊还分析应用场景比较特殊，所以我们并不会经常用到。为了方便你理解、记忆，我这里简单总结一下它们的应用场景。如果你遇到了，知道是怎么回事儿就行了。</p>\n<p>对一个数据结构进行一组连续操作中，大部分情况下时间复杂度都很低，只有个别情况下时间复杂度比较高，而且这些操作之间存在前后连贯的时序关系，这个时候，我们就可以将这一组操作放在一块儿分析，看是否能将较高时间复杂度那次操作的耗时，平摊到其他那些时间复杂度比较低的操作上。而且，在能够应用均摊时间复杂度分析的场合，一般均摊时间复杂度就等于最好情况时间复杂度。</p>\n<p>尽管很多数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度，但其实我个人认为，<strong>均摊时间复杂度就是一种特殊的平均时间复杂度</strong>，我们没必要花太多精力去区分它们。你最应该掌握的是它的分析方法，摊还分析。至于分析出来的结果是叫平均还是叫均摊，这只是个说法，并不重要。</p>\n<h2>内容小结</h2>\n<p>今天我们学习了几个复杂度分析相关的概念，分别有：最好情况时间复杂度、最坏情况时间复杂度、平均情况时间复杂度、均摊时间复杂度。之所以引入这几个复杂度概念，是因为，同一段代码，在不同输入的情况下，复杂度量级有可能是不一样的。</p>\n<p>在引入这几个概念之后，我们可以更加全面地表示一段代码的执行效率。而且，这几个概念理解起来都不难。最好、最坏情况下的时间复杂度分析起来比较简单，但平均、均摊两个复杂度分析相对比较复杂。如果你觉得理解得还不是很深入，不用担心，在后续具体的数据结构和算法学习中，我们可以继续慢慢实践！</p>\n<h2>课后思考</h2>\n<p>我们今天学的几个复杂度分析方法，你都掌握了吗？你可以用今天学习的知识，来分析一下下面这个add()函数的时间复杂度。</p>\n<pre><code>// 全局变量，大小为10的数组array，长度len，下标i。\nint array[] = new int[10]; \nint len = 10;\nint i = 0;\n\n// 往数组中添加一个元素\nvoid add(int element) {\n   if (i &gt;= len) { // 数组空间不够了\n     // 重新申请一个2倍大小的数组空间\n     int new_array[] = new int[len*2];\n     // 把原来array数组中的数据依次copy到new_array\n     for (int j = 0; j &lt; len; ++j) {\n       new_array[j] = array[j];\n     }\n     // new_array复制给array，array现在大小就是2倍len了\n     array = new_array;\n     len = 2 * len;\n   }\n   // 将element放到下标为i的位置，下标i加一\n   array[i] = element;\n   ++i;\n}\n</code></pre>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"04 | 复杂度分析（下）：浅析最好、最坏、平均、均摊时间复杂度",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/e6/a1/e6e57438c034f24899cabc658ee62ba1.mp3",
		"column_id":126,
		"id":40447,
		"site_source_url":"https://time.geekbang.org/column/article/40447"
	},
	{
		"article_content":"<p>我们都知道，数据结构和算法本身解决的是“快”和“省”的问题，即如何让代码运行得更快，如何让代码更省存储空间。所以，执行效率是算法一个非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢？这里就要用到我们今天要讲的内容：时间、空间复杂度分析。</p>\n<p>其实，只要讲到数据结构与算法，就一定离不开时间、空间复杂度分析。而且，我个人认为，<strong>复杂度分析是整个算法学习的精髓，只要掌握了它，数据结构和算法的内容基本上就掌握了一半</strong>。</p>\n<p>复杂度分析实在太重要了，因此我准备用两节内容来讲。希望你学完这个内容之后，无论在任何场景下，面对任何代码的复杂度分析，你都能做到“庖丁解牛”般游刃有余。</p>\n<h2>为什么需要复杂度分析？</h2>\n<p>你可能会有些疑惑，我把代码跑一遍，通过统计、监控，就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢？这种分析方法能比我实实在在跑一遍得到的数据更准确吗？</p>\n<p>首先，我可以肯定地说，你这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字，叫<strong>事后统计法</strong>。但是，这种统计方法有非常大的局限性。</p>\n<p><strong>1. 测试结果非常依赖测试环境</strong></p>\n<p>测试环境中硬件的不同会对测试结果有很大的影响。比如，我们拿同样一段代码，分别用Intel Core i9处理器和Intel Core i3处理器来运行，不用说，i9处理器要比i3处理器执行的速度快很多。还有，比如原本在这台机器上a代码执行的速度比b代码要快，等我们换到另一台机器上时，可能会有截然相反的结果。</p><!-- [[[read_end]]] -->\n<p><strong>2.测试结果受数据规模的影响很大</strong></p>\n<p>后面我们会讲排序算法，我们先拿它举个例子。对同一个排序算法，待排序数据的有序度不一样，排序的执行时间就会有很大的差别。极端情况下，如果数据已经是有序的，那排序算法不需要做任何操作，执行时间就会非常短。除此之外，如果测试数据规模太小，测试结果可能无法真实地反应算法的性能。比如，对于小规模的数据排序，插入排序可能反倒会比快速排序要快！</p>\n<p>所以，<strong>我们需要一个不用具体的测试数据来测试，就可以粗略地估计算法的执行效率的方法</strong>。这就是我们今天要讲的时间、空间复杂度分析方法。</p>\n<h2>大O复杂度表示法</h2>\n<p>算法的执行效率，粗略地讲，就是算法代码执行的时间。但是，如何在不运行代码的情况下，用“肉眼”得到一段代码的执行时间呢？</p>\n<p>这里有段非常简单的代码，求1,2,3…n的累加和。现在，我就带你一块来估算一下这段代码的执行时间。</p>\n<pre><code> int cal(int n) {\n   int sum = 0;\n   int i = 1;\n   for (; i &lt;= n; ++i) {\n     sum = sum + i;\n   }\n   return sum;\n }\n</code></pre>\n<p>从CPU的角度来看，这段代码的每一行都执行着类似的操作：<strong>读数据</strong>-<strong>运算</strong>-<strong>写数据</strong>。尽管每行代码对应的CPU执行的个数、执行的时间都不一样，但是，我们这里只是粗略估计，所以可以假设每行代码执行的时间都一样，为unit_time。在这个假设的基础之上，这段代码的总执行时间是多少呢？</p>\n<p>第2、3行代码分别需要1个unit_time的执行时间，第4、5行都运行了n遍，所以需要2n*unit_time的执行时间，所以这段代码总的执行时间就是(2n+2)*unit_time。可以看出来，<strong>所有代码的执行时间T(n)与每行代码的执行次数成正比</strong>。</p>\n<p>按照这个分析思路，我们再来看这段代码。</p>\n<pre><code> int cal(int n) {\n   int sum = 0;\n   int i = 1;\n   int j = 1;\n   for (; i &lt;= n; ++i) {\n     j = 1;\n     for (; j &lt;= n; ++j) {\n       sum = sum +  i * j;\n     }\n   }\n }\n</code></pre>\n<p>我们依旧假设每个语句的执行时间是unit_time。那这段代码的总执行时间T(n)是多少呢？</p>\n<p>第2、3、4行代码，每行都需要1个unit_time的执行时间，第5、6行代码循环执行了n遍，需要2n * unit_time的执行时间，第7、8行代码循环执行了n<sup>2</sup>遍，所以需要2n<sup>2</sup> * unit_time的执行时间。所以，整段代码总的执行时间T(n) = (2n<sup>2</sup>+2n+3)*unit_time。</p>\n<p>尽管我们不知道unit_time的具体值，但是通过这两段代码执行时间的推导过程，我们可以得到一个非常重要的规律，那就是，<strong><span class=\"orange\">所有代码的执行时间T(n)与每行代码的执行次数n成正比</span></strong>。</p>\n<p>我们可以把这个规律总结成一个公式。注意，大O就要登场了！</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/22/ef/22900968aa2b190072c985a08b0e92ef.png\" alt=\"\" /></p>\n<p>我来具体解释一下这个公式。其中，T(n)我们已经讲过了，它表示代码执行的时间；n表示数据规模的大小；f(n)表示每行代码执行的次数总和。因为这是一个公式，所以用f(n)来表示。公式中的O，表示代码的执行时间T(n)与f(n)表达式成正比。</p>\n<p>所以，第一个例子中的T(n) = O(2n+2)，第二个例子中的T(n) = O(2n<sup>2</sup>+2n+3)。这就是<strong>大O时间复杂度表示法</strong>。大O时间复杂度实际上并不具体表示代码真正的执行时间，而是表示<strong>代码执行时间随数据规模增长的变化趋势</strong>，所以，也叫作<strong>渐进时间复杂度</strong>（asymptotic time complexity），简称<strong>时间复杂度</strong>。</p>\n<p>当n很大时，你可以把它想象成10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势，所以都可以忽略。我们只需要记录一个最大量级就可以了，如果用大O表示法表示刚讲的那两段代码的时间复杂度，就可以记为：T(n) = O(n)； T(n) = O(n<sup>2</sup>)。</p>\n<h2>时间复杂度分析</h2>\n<p>前面介绍了大O时间复杂度的由来和表示方法。现在我们来看下，如何分析一段代码的时间复杂度？我这儿有三个比较实用的方法可以分享给你。</p>\n<p><strong>1.只关注循环执行次数最多的一段代码</strong></p>\n<p>我刚才说了，大O这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数，只需要记录一个最大阶的量级就可以了。所以，<strong>我们在分析一个算法、一段代码的时间复杂度的时候，也只关注循环执行次数最多的那一段代码就可以了</strong>。这段核心代码执行次数的n的量级，就是整段要分析代码的时间复杂度。</p>\n<p>为了便于你理解，我还拿前面的例子来说明。</p>\n<pre><code> int cal(int n) {\n   int sum = 0;\n   int i = 1;\n   for (; i &lt;= n; ++i) {\n     sum = sum + i;\n   }\n   return sum;\n }\n</code></pre>\n<p>其中第2、3行代码都是常量级的执行时间，与n的大小无关，所以对于复杂度并没有影响。循环执行次数最多的是第4、5行代码，所以这块代码要重点分析。前面我们也讲过，这两行代码被执行了n次，所以总的时间复杂度就是O(n)。</p>\n<p><strong>2.加法法则：总复杂度等于量级最大的那段代码的复杂度</strong></p>\n<p>我这里还有一段代码。你可以先试着分析一下，然后再往下看跟我的分析思路是否一样。</p>\n<pre><code>int cal(int n) {\n   int sum_1 = 0;\n   int p = 1;\n   for (; p &lt; 100; ++p) {\n     sum_1 = sum_1 + p;\n   }\n\n   int sum_2 = 0;\n   int q = 1;\n   for (; q &lt; n; ++q) {\n     sum_2 = sum_2 + q;\n   }\n \n   int sum_3 = 0;\n   int i = 1;\n   int j = 1;\n   for (; i &lt;= n; ++i) {\n     j = 1; \n     for (; j &lt;= n; ++j) {\n       sum_3 = sum_3 +  i * j;\n     }\n   }\n \n   return sum_1 + sum_2 + sum_3;\n }\n</code></pre>\n<p>这个代码分为三部分，分别是求sum_1、sum_2、sum_3。我们可以分别分析每一部分的时间复杂度，然后把它们放到一块儿，再取一个量级最大的作为整段代码的复杂度。</p>\n<p>第一段的时间复杂度是多少呢？这段代码循环执行了100次，所以是一个常量的执行时间，跟n的规模无关。</p>\n<p>这里我要再强调一下，即便这段代码循环10000次、100000次，只要是一个已知的数，跟n无关，照样也是常量级的执行时间。当n无限大的时候，就可以忽略。尽管对代码的执行时间会有很大影响，但是回到时间复杂度的概念来说，它表示的是一个算法执行效率与数据规模增长的变化趋势，所以不管常量的执行时间多大，我们都可以忽略掉。因为它本身对增长趋势并没有影响。</p>\n<p>那第二段代码和第三段代码的时间复杂度是多少呢？答案是O(n)和O(n<sup>2</sup>)，你应该能容易就分析出来，我就不啰嗦了。</p>\n<p>综合这三段代码的时间复杂度，我们取其中最大的量级。所以，整段代码的时间复杂度就为O(n<sup>2</sup>)。也就是说：<strong>总的时间复杂度<strong><strong>就</strong></strong>等于量级最大的那段代码的时间复杂度</strong>。那我们将这个规律抽象成公式就是：</p>\n<p>如果T1(n)=O(f(n))，T2(n)=O(g(n))；那么T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).</p>\n<p><strong>3.乘法法则：嵌套代码的复杂度等于嵌套内外代码复杂度的乘积</strong></p>\n<p>我刚讲了一个复杂度分析中的加法法则，这儿还有一个<strong>乘法法则</strong>。类比一下，你应该能“猜到”公式是什么样子的吧？</p>\n<p>如果T1(n)=O(f(n))，T2(n)=O(g(n))；那么T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)).</p>\n<p>也就是说，假设T1(n) = O(n)，T2(n) = O(n<sup>2</sup>)，则T1(n) * T2(n) = O(n<sup>3</sup>)。落实到具体的代码上，我们可以把乘法法则看成是<strong>嵌套循环</strong>，我举个例子给你解释一下。</p>\n<pre><code>int cal(int n) {\n   int ret = 0; \n   int i = 1;\n   for (; i &lt; n; ++i) {\n     ret = ret + f(i);\n   } \n } \n \n int f(int n) {\n  int sum = 0;\n  int i = 1;\n  for (; i &lt; n; ++i) {\n    sum = sum + i;\n  } \n  return sum;\n }\n</code></pre>\n<p>我们单独看cal()函数。假设f()只是一个普通的操作，那第4～6行的时间复杂度就是，T1(n) = O(n)。但f()函数本身不是一个简单的操作，它的时间复杂度是T2(n) = O(n)，所以，整个cal()函数的时间复杂度就是，T(n) = T1(n) * T2(n) = O(n*n) = O(n<sup>2</sup>)。</p>\n<p>我刚刚讲了三种复杂度的分析技巧。不过，你并不用刻意去记忆。实际上，复杂度分析这个东西关键在于“熟练”。你只要多看案例，多分析，就能做到“无招胜有招”。</p>\n<h2>几种常见时间复杂度实例分析</h2>\n<p>虽然代码千差万别，但是常见的复杂度量级并不多。我稍微总结了一下，这些复杂度量级几乎涵盖了你今后可以接触的所有代码的复杂度量级。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/37/0a/3723793cc5c810e9d5b06bc95325bf0a.jpg\" alt=\"\" /></p>\n<p>对于刚罗列的复杂度量级，我们可以粗略地分为两类，<strong>多项式量级</strong>和<strong>非多项式量级</strong>。其中，非多项式量级只有两个：O(2<sup>n</sup>)和O(n!)。</p>\n<p>当数据规模n越来越大时，非多项式量级算法的执行时间会急剧增加，求解问题的执行时间会无限增长。所以，非多项式时间复杂度的算法其实是非常低效的算法。因此，关于NP时间复杂度我就不展开讲了。我们主要来看几种常见的<strong>多项式时间复杂度</strong>。</p>\n<p><strong>1. O(1)</strong></p>\n<p>首先你必须明确一个概念，O(1)只是常量级时间复杂度的一种表示方法，并不是指只执行了一行代码。比如这段代码，即便有3行，它的时间复杂度也是O(1），而不是O(3)。</p>\n<pre><code> int i = 8;\n int j = 6;\n int sum = i + j;\n</code></pre>\n<p>我稍微总结一下，只要代码的执行时间不随n的增大而增长，这样代码的时间复杂度我们都记作O(1)。或者说，<strong>一般<strong><strong>情况下</strong></strong>，只要算法中不存在循环语句、递归语句，即使有成千上万行的代码，其时间复杂度也是Ο(1)</strong>。</p>\n<p><strong>2.  O(logn)、O(nlogn)</strong></p>\n<p>对数阶时间复杂度非常常见，同时也是最难分析的一种时间复杂度。我通过一个例子来说明一下。</p>\n<pre><code> i=1;\n while (i &lt;= n)  {\n   i = i * 2;\n }\n</code></pre>\n<p>根据我们前面讲的复杂度分析方法，第三行代码是循环执行次数最多的。所以，我们只要能计算出这行代码被执行了多少次，就能知道整段代码的时间复杂度。</p>\n<p>从代码中可以看出，变量i的值从1开始取，每循环一次就乘以2。当大于n时，循环结束。还记得我们高中学过的等比数列吗？实际上，变量i的取值就是一个等比数列。如果我把它一个一个列出来，就应该是这个样子的：</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/9b/9a/9b1c88264e7a1a20b5954be9bc4bec9a.jpg\" alt=\"\" /></p>\n<p>所以，我们只要知道x值是多少，就知道这行代码执行的次数了。通过2<sup>x</sup>=n求解x这个问题我们想高中应该就学过了，我就不多说了。x=log<sub>2</sub>n，所以，这段代码的时间复杂度就是O(log<sub>2</sub>n)。</p>\n<p>现在，我把代码稍微改下，你再看看，这段代码的时间复杂度是多少？</p>\n<pre><code> i=1;\n while (i &lt;= n)  {\n   i = i * 3;\n }\n</code></pre>\n<p>根据我刚刚讲的思路，很简单就能看出来，这段代码的时间复杂度为O(log<sub>3</sub>n)。</p>\n<p>实际上，不管是以2为底、以3为底，还是以10为底，我们可以把所有对数阶的时间复杂度都记为O(logn)。为什么呢？</p>\n<p>我们知道，对数之间是可以互相转换的，log<sub>3</sub>n就等于log<sub>3</sub>2 * log<sub>2</sub>n，所以O(log<sub>3</sub>n) = O(C *  log<sub>2</sub>n)，其中C=log<sub>3</sub>2是一个常量。基于我们前面的一个理论：<strong>在采用大O标记复杂度的时候，可以忽略系数，即O(Cf(n)) = O(f(n))</strong>。所以，O(log<sub>2</sub>n) 就等于O(log<sub>3</sub>n)。因此，在对数阶时间复杂度的表示方法里，我们忽略对数的“底”，统一表示为O(logn)。</p>\n<p>如果你理解了我前面讲的O(logn)，那O(nlogn)就很容易理解了。还记得我们刚讲的乘法法则吗？如果一段代码的时间复杂度是O(logn)，我们循环执行n遍，时间复杂度就是O(nlogn)了。而且，O(nlogn)也是一种非常常见的算法时间复杂度。比如，归并排序、快速排序的时间复杂度都是O(nlogn)。</p>\n<p><strong>3.  O(m+n)、O(m*n)</strong></p>\n<p>我们再来讲一种跟前面都不一样的时间复杂度，代码的复杂度<strong>由两个数据的规模</strong>来决定。老规矩，先看代码！</p>\n<pre><code>int cal(int m, int n) {\n  int sum_1 = 0;\n  int i = 1;\n  for (; i &lt; m; ++i) {\n    sum_1 = sum_1 + i;\n  }\n\n  int sum_2 = 0;\n  int j = 1;\n  for (; j &lt; n; ++j) {\n    sum_2 = sum_2 + j;\n  }\n\n  return sum_1 + sum_2;\n}\n</code></pre>\n<p>从代码中可以看出，m和n是表示两个数据规模。我们无法事先评估m和n谁的量级大，所以我们在表示复杂度的时候，就不能简单地利用加法法则，省略掉其中一个。所以，上面代码的时间复杂度就是O(m+n)。</p>\n<p>针对这种情况，原来的加法法则就不正确了，我们需要将加法规则改为：T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法则继续有效：T1(m)*T2(n) = O(f(m) * f(n))。</p>\n<h2>空间复杂度分析</h2>\n<p>前面，咱们花了很长时间讲大O表示法和时间复杂度分析，理解了前面讲的内容，空间复杂度分析方法学起来就非常简单了。</p>\n<p>前面我讲过，时间复杂度的全称是<strong>渐进时间复杂度</strong>，<strong>表示算法的执行时间与数据规模之间的增长关系</strong>。类比一下，空间复杂度全称就是<strong>渐进空间复杂度</strong>（asymptotic space complexity），<strong>表示算法的存储空间与数据规模之间的增长关系</strong>。</p>\n<p>我还是拿具体的例子来给你说明。（这段代码有点“傻”，一般没人会这么写，我这么写只是为了方便给你解释。）</p>\n<pre><code>void print(int n) {\n  int i = 0;\n  int[] a = new int[n];\n  for (i; i &lt;n; ++i) {\n    a[i] = i * i;\n  }\n\n  for (i = n-1; i &gt;= 0; --i) {\n    print out a[i]\n  }\n}\n</code></pre>\n<p>跟时间复杂度分析一样，我们可以看到，第2行代码中，我们申请了一个空间存储变量i，但是它是常量阶的，跟数据规模n没有关系，所以我们可以忽略。第3行申请了一个大小为n的int类型数组，除此之外，剩下的代码都没有占用更多的空间，所以整段代码的空间复杂度就是O(n)。</p>\n<p>我们常见的空间复杂度就是O(1)、O(n)、O(n<sup>2</sup> )，像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到。而且，空间复杂度分析比时间复杂度分析要简单很多。所以，对于空间复杂度，掌握刚我说的这些内容已经足够了。</p>\n<h2>内容小结</h2>\n<p>基础复杂度分析的知识到此就讲完了，我们来总结一下。</p>\n<p>复杂度也叫渐进复杂度，包括时间复杂度和空间复杂度，用来分析算法执行效率与数据规模之间的增长关系，可以粗略地表示，越高阶复杂度的算法，执行效率越低。常见的复杂度并不多，从低阶到高阶有：O(1)、O(logn)、O(n)、O(nlogn)、O(n<sup>2</sup> )。等你学完整个专栏之后，你就会发现几乎所有的数据结构和算法的复杂度都跑不出这几个。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/49/04/497a3f120b7debee07dc0d03984faf04.jpg\" alt=\"\" /></p>\n<p><strong>复杂度分析并不难，关键在于多练。</strong> 之后讲后面的内容时，我还会带你详细地分析每一种数据结构和算法的时间、空间复杂度。只要跟着我的思路学习、练习，你很快就能和我一样，每次看到代码的时候，简单的一眼就能看出其复杂度，难的稍微分析一下就能得出答案。</p>\n<h2>课后思考</h2>\n<p>有人说，我们项目之前都会进行性能测试，再做代码的时间复杂度、空间复杂度分析，是不是多此一举呢？而且，每段代码都分析一下时间复杂度、空间复杂度，是不是很浪费时间呢？你怎么看待这个问题呢？</p>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"03 | 复杂度分析（上）：如何分析、统计算法的执行效率和资源消耗？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/74/a9/74e20b5883f0d5ce4603b0fd6c398aa9.mp3",
		"column_id":126,
		"id":40036,
		"site_source_url":"https://time.geekbang.org/column/article/40036"
	},
	{
		"article_content":"<p>你是否曾跟我一样，因为看不懂数据结构和算法，而一度怀疑是自己太笨？实际上，很多人在第一次接触这门课时，都会有这种感觉，觉得数据结构和算法很抽象，晦涩难懂，宛如天书。正是这个原因，让很多初学者对这门课望而却步。</p>\n<p>我个人觉得，其实真正的原因是你<strong>没有找到好的学习方法</strong>，<strong>没有抓住学习的重点</strong>。实际上，数据结构和算法的东西并不多，常用的、基础的知识点更是屈指可数。只要掌握了正确的学习方法，学起来并没有看上去那么难，更不需要什么高智商、厚底子。</p>\n<p>还记得大学里每次考前老师都要划重点吗？今天，我就给你划划我们这门课的重点，再告诉你一些我总结的学习小窍门。相信有了这些之后，你学起来就会有的放矢、事半功倍了。</p>\n<h2>什么是数据结构？什么是算法？</h2>\n<p>大部分数据结构和算法教材，在开篇都会给这两个概念下一个明确的定义。但是，这些定义都很抽象，对理解这两个概念并没有实质性的帮助，反倒会让你陷入死抠定义的误区。毕竟，我们现在学习，并不是为了考试，所以，概念背得再牢，不会用也就没什么用。</p>\n<p><strong>虽然我们说没必要深挖严格的定义，但是这并不等于不需要理解概念。</strong> 下面我就从广义和狭义两个层面，来帮你理解数据结构与算法这两个概念。</p>\n<p>从广义上讲，数据结构就是指一组数据的存储结构。算法就是操作数据的一组方法。</p><!-- [[[read_end]]] -->\n<p>图书馆储藏书籍你肯定见过吧？为了方便查找，图书管理员一般会将书籍分门别类进行“存储”。按照一定规律编号，就是书籍这种“数据”的存储结构。</p>\n<p>那我们如何来查找一本书呢？有很多种办法，你当然可以一本一本地找，也可以先根据书籍类别的编号，是人文，还是科学、计算机，来定位书架，然后再依次查找。笼统地说，这些查找方法都是算法。</p>\n<p>从狭义上讲，也就是我们专栏要讲的，是指某些著名的数据结构和算法，比如队列、栈、堆、二分查找、动态规划等。这些都是前人智慧的结晶，我们可以直接拿来用。我们要讲的这些经典数据结构和算法，都是前人从很多实际操作场景中抽象出来的，经过非常多的求证和检验，可以高效地帮助我们解决很多实际的开发问题。</p>\n<p>那数据结构和算法有什么关系呢？为什么大部分书都把这两个东西放到一块儿来讲呢？</p>\n<p>这是因为，数据结构和算法是相辅相成的。<strong>数据结构是为算法服务的，算法要作用在特定的数据结构之上。</strong> 因此，我们无法孤立数据结构来讲算法，也无法孤立算法来讲数据结构。</p>\n<p>比如，因为数组具有随机访问的特点，常用的二分查找算法需要用数组来存储数据。但如果我们选择链表这种数据结构，二分查找算法就无法工作了，因为链表并不支持随机访问。</p>\n<p>数据结构是静态的，它只是组织数据的一种方式。如果不在它的基础上操作、构建算法，孤立存在的数据结构就是没用的。</p>\n<p>现在你对数据结构与算法是不是有了比较清晰的理解了呢？有了这些储备，下面我们来看看，究竟该怎么学数据结构与算法。</p>\n<h2>学习这个专栏需要什么基础？</h2>\n<p>看到数据结构和算法里的“算法”两个字，很多人就会联想到“数学”，觉得算法会涉及到很多深奥的数学知识。那我数学基础不是很好，学起来会不会很吃力啊？</p>\n<p>数据结构和算法课程确实会涉及一些数学方面的推理、证明，尤其是在分析某个算法的时间、空间复杂度的时候，但是这个你完全不需要担心。</p>\n<p>这个专栏不会像《算法导论》那样，里面有非常复杂的数学证明和推理。我会由浅入深，从概念到应用，一点一点给你解释清楚。你只要有高中数学水平，就完全可以学习。</p>\n<p>当然，我希望你最好有些编程基础，如果有项目经验就更好了。这样我给你讲数据结构和算法如何提高效率、如何节省存储空间，你就会有很直观的感受。因为，对于每个概念和实现过程，我都会从实际场景出发，不仅教你“<strong>是什么</strong>”，还会教你“<strong>为什么</strong>”，并且告诉你遇到同类型问题应该“<strong>怎么做</strong>”。</p>\n<h2>学习的重点在什么地方？</h2>\n<p>提到数据结构和算法，很多人就很头疼，因为这里面的内容实在是太多了。这里，我就帮你梳理一下，应该先学什么，后学什么。你可以对照看看，你属于哪个阶段，然后有针对地进行学习。</p>\n<p>想要学习数据结构与算法，<strong>首先要掌握一个数据结构与算法中最重要的概念——复杂度分析。</strong></p>\n<p>这个概念究竟有多重要呢？可以这么说，它几乎占了数据结构和算法这门课的半壁江山，是数据结构和算法学习的精髓。</p>\n<p>数据结构和算法解决的是如何更省、更快地存储和处理数据的问题，因此，我们就需要一个考量效率和资源消耗的方法，这就是复杂度分析方法。所以，如果你只掌握了数据结构和算法的特点、用法，但是没有学会复杂度分析，那就相当于只知道操作口诀，而没掌握心法。只有把心法了然于胸，才能做到无招胜有招！</p>\n<p>所以，复杂度分析这个内容，我会用很大篇幅给你讲透。你也一定要花大力气来啃，必须要拿下，并且要搞得非常熟练。否则，后面的数据结构和算法也很难学好。</p>\n<p>搞定复杂度分析，下面就要进入<strong>数据结构与算法的正文内容</strong>了。</p>\n<p>为了让你对数据结构和算法能有个全面的认识，我画了一张图，里面几乎涵盖了所有数据结构和算法书籍中都会讲到的知识点。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/91/a7/913e0ababe43a2d57267df5c5f0832a7.jpg\" alt=\"\" /><br />\n<span class=\"reference\">（图谱内容较多，建议长按保存后浏览）</span></p>\n<p>但是，作为初学者，或者一个非算法工程师来说，你并不需要掌握图里面的所有知识点。很多高级的数据结构与算法，比如二分图、最大流等，这些在我们平常的开发中很少会用到。所以，你暂时可以不用看。我还是那句话，咱们学习要学会找重点。如果不分重点地学习，眉毛胡子一把抓，学起来肯定会比较吃力。</p>\n<p>所以，结合我自己的学习心得，还有这些年的面试、开发经验，我总结了<strong>20个最常用的、最基础</strong>数据结构与算法，<strong>不管是应付面试还是工作需要，只要集中精力逐一攻克这20个知识点就足够了。</strong></p>\n<p>这里面有10个数据结构：<span class=\"orange\">数组</span>、<span class=\"orange\">链表</span>、<span class=\"orange\">栈</span>、<span class=\"orange\">队列</span>、<span class=\"orange\">散列表</span>、<span class=\"orange\">二叉树</span>、<span class=\"orange\">堆</span>、<span class=\"orange\">跳表</span>、<span class=\"orange\">图</span>、<span class=\"orange\">Trie树</span>；10个算法：<span class=\"orange\">递归</span>、<span class=\"orange\">排序</span>、<span class=\"orange\">二分查找</span>、<span class=\"orange\">搜索</span>、<span class=\"orange\">哈希算法</span>、<span class=\"orange\">贪心算法</span>、<span class=\"orange\">分治算法</span>、<span class=\"orange\">回溯算法</span>、<span class=\"orange\">动态规划</span>、<span class=\"orange\">字符串匹配算法</span>。</p>\n<p>掌握了这些基础的数据结构和算法，再学更加复杂的数据结构和算法，就会非常容易、非常快。</p>\n<p>在学习数据结构和算法的过程中，你也要注意，不要只是死记硬背，不要为了学习而学习，而是<strong>要学习它的“来历”“自身的特点”“适合解决的问题”以及“实际的应用场景”</strong>。对于每一种数据结构或算法，我都会从这几个方面进行详细讲解。只要你掌握了我每节课里讲的内容，就能在开发中灵活应用。</p>\n<p>学习数据结构和算法的过程，是非常好的思维训练的过程，所以，千万不要被动地记忆，要多辩证地思考，多问为什么。如果你一直这么坚持做，你会发现，等你学完之后，写代码的时候就会不由自主地考虑到很多性能方面的事情，时间复杂度、空间复杂度非常高的垃圾代码出现的次数就会越来越少。你的编程内功就真正得到了修炼。</p>\n<h2>一些可以让你事半功倍的学习技巧</h2>\n<p>前面我给你划了学习的重点，也讲了学习这门课需要具备的基础。作为一个过来人，现在我就给你分享一下，专栏学习的一些技巧。掌握了这些技巧，可以让你化被动为主动，学起来更加轻松，更加有动力！</p>\n<h3>1.边学边练，适度刷题</h3>\n<p>“边学边练”这一招非常有用。建议你每周花1～2个小时的时间，集中把这周的三节内容涉及的数据结构和算法，全都自己写出来，用代码实现一遍。这样一定会比单纯地看或者听的效果要好很多！</p>\n<p>有面试需求的同学，可能会问了，那我还要不要去刷题呢？</p>\n<p>我个人的观点是<strong>可以“适度”刷题，但一定不要浪费太多时间在刷题上</strong>。我们<strong>学习的目的还是掌握，然后应用</strong>。除非你要面试Google、Facebook这样的公司，它们的算法题目非常非常难，必须大量刷题，才能在短期内提升应试正确率。如果是应对国内公司的技术面试，即便是BAT这样的公司，你只要彻底掌握这个专栏的内容，就足以应对。</p>\n<h3>2.多问、多思考、多互动</h3>\n<p><strong>学习最好的方法是，找到几个人一起学习，一块儿讨论切磋，有问题及时寻求老师答疑。</strong> 但是，离开大学之后，既没有同学也没有老师，这个条件就比较难具备了。</p>\n<p>不过，这也就是咱们专栏学习的优势。专栏里有很多跟你一样的学习者。你可以多在留言区写下自己的疑问、思考和总结，也可以经常看看别人的留言，和他们进行互动。</p>\n<p>除此之外，如果你有疑问，你可以随时在留言区给我留言，我只要有空就会及时回复你。你不要担心问的问题太小白。因为我初学的时候，也常常会被一些小白问题困扰。不懂一点都不丢人，只要你勇敢提出来，我们一起解决了就可以了。</p>\n<p>我也会力争每节课都最大限度地给你讲透，帮你扫除知识盲点，而你要做的就是，避免一知半解，要想尽一切办法去搞懂我讲的所有内容。</p>\n<h3>3.打怪升级学习法</h3>\n<p><strong>学习的过程中，我们碰到最大的问题就是，坚持不下来。</strong> 是的，很多基础课程学起来都非常枯燥。为此，我自己总结了一套“打怪升级学习法”。</p>\n<p>游戏你肯定玩过吧？为什么很多看起来非常简单又没有乐趣的游戏，你会玩得不亦乐乎呢？这是因为，当你努力打到一定级别之后，每天看着自己的经验值、战斗力在慢慢提高，那种每天都在一点一点成长的成就感就不由自主地产生了。</p>\n<p>所以，<strong>我们在枯燥的学习过程中，也可以给自己设立一个切实可行的目标</strong>，就像打怪升级一样。</p>\n<p>比如，针对这个专栏，你就可以设立这样一个目标：每节课后的思考题都认真思考，并且回复到留言区。当你看到很多人给你点赞之后，你就会为了每次都能发一个漂亮的留言，而更加认真地学习。</p>\n<p>当然，还有很多其他的目标，比如，每节课后都写一篇学习笔记或者学习心得；或者你还可以每节课都找一下我讲得不对、不合理的地方……诸如此类，你可以总结一个适合你的“打怪升级攻略”。</p>\n<p>如果你能这样学习一段时间，不仅能收获到知识，你还会有意想不到的成就感。因为，这其实帮你改掉了一点学习的坏习惯。这个习惯一旦改掉了，你的人生也会变得不一样。</p>\n<h3>4.知识需要沉淀，不要想试图一下子掌握所有</h3>\n<p>在学习的过程中，一定会碰到“拦路虎”。如果哪个知识点没有怎么学懂，不要着急，这是正常的。因为，想听一遍、看一遍就把所有知识掌握，这肯定是不可能的。<strong>学习<strong><strong>知识的</strong></strong>过程是反复迭代、不断沉淀的过程。</strong></p>\n<p>如果碰到“拦路虎”，你可以尽情地在留言区问我，也可以先沉淀一下，过几天再重新学一遍。所谓，书读百遍其义自见，我觉得是很有道理的！</p>\n<p>我讲的这些学习方法，不仅仅针对咱们这一个课程的学习，其实完全适用任何知识的学习过程。你可以通过这个专栏的学习，实践一下这些方法。如果效果不错，再推广到之后的学习过程中。</p>\n<h2>内容小结</h2>\n<p>今天，我带你划了划数据结构和算法的学习重点，复杂度分析，以及10个数据结构和10个算法。</p>\n<p>这些内容是我根据平时的学习和工作、面试经验积累，精心筛选出来的。只要掌握这些内容，应付日常的面试、工作，基本不会有问题。</p>\n<p>除此之外，我还给你分享了我总结的一些学习技巧，比如边学边练、多问、多思考，还有两个比较通用的学习方法，打怪升级法和沉淀法。掌握了这些学习技巧，可以让你学习过程中事半功倍。所以，你一定要好好实践哦！</p>\n<h2>课后思考</h2>\n<p>今天的内容是一个准备课，从下节开始，我们就要正式开始学习精心筛选出的这20个数据结构和算法了。所以，今天给你布置一个任务，对照我上面讲的“打怪升级学习法”，请思考一下你自己学习这个专栏的方法，让我们一起在留言区立下Flag，相互鼓励！</p>\n<p>另外，你在之前学习数据结构和算法的过程中，遇到过什么样的困难或者疑惑吗？</p>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"02 | 如何抓住重点，系统高效地学习数据结构与算法？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/3c/ae/3cb2135163e937bf84a90066303a94ae.mp3",
		"column_id":126,
		"id":40011,
		"site_source_url":"https://time.geekbang.org/column/article/40011"
	},
	{
		"article_content":"<p>你是不是觉得数据结构和算法，跟操作系统、计算机网络一样，是脱离实际工作的知识？可能除了面试，这辈子也用不着？</p>\n<p>尽管计算机相关专业的同学在大学都学过这门课程，甚至很多培训机构也会培训这方面的知识，但是据我了解，很多程序员对数据结构和算法依旧一窍不通。还有一些人也只听说过数组、链表、快排这些最最基本的数据结构和算法，稍微复杂一点的就完全没概念。</p>\n<p>当然，也有很多人说，自己实际工作中根本用不到数据结构和算法。所以，就算不懂这块知识，只要Java API、开发框架用得熟练，照样可以把代码写得“飞”起来。事实真的是这样吗？</p>\n<p>今天我们就来详细聊一聊，为什么要学习数据结构和算法。</p>\n<h2>想要通关大厂面试，千万别让数据结构和算法拖了后腿</h2>\n<p>很多大公司，比如BAT、Google、Facebook，面试的时候都喜欢考算法、让人现场写代码。有些人虽然技术不错，但每次去面试都会“跪”在算法上，很是可惜。那你有没有想过，为什么这些大公司都喜欢考算法呢？</p>\n<p>校招的时候，参加面试的学生通常没有实际项目经验，公司只能考察他们的基础知识是否牢固。社招就更不用说了，越是厉害的公司，越是注重考察数据结构与算法这类基础知识。相比短期能力，他们更看中你的长期潜力。</p><!-- [[[read_end]]] -->\n<p>你可能要说了，我不懂数据结构与算法，照样找到了好工作啊。那我是不是就不用学数据结构和算法呢？当然不是，你别忘了，<strong>我们学任何知识都是为了“用”的，是为了解决实际工作问题的</strong>，学习数据结构和算法自然也不例外。</p>\n<h2>业务开发工程师，你真的愿意做一辈子CRUD boy吗？</h2>\n<p>如果你是一名业务开发工程师，你可能要说，我整天就是做数据库CRUD（增删改查），哪里用得到数据结构和算法啊？</p>\n<p>是的，对于大部分业务开发来说，我们平时可能更多的是利用已经封装好的现成的接口、类库来堆砌、翻译业务逻辑，很少需要自己实现数据结构和算法。但是，<strong>不需要自己实现，并不代表什么都不需要了解</strong>。</p>\n<p>如果不知道这些类库背后的原理，不懂得时间、空间复杂度分析，你如何能用好、用对它们？存储某个业务数据的时候，你如何知道应该用ArrayList，还是Linked List呢？调用了某个函数之后，你又该如何评估代码的性能和资源的消耗呢？</p>\n<p>作为业务开发，我们会用到各种框架、中间件和底层系统，比如Spring、RPC框架、消息中间件、Redis等等。<strong>在这些基础框架中，一般都揉和了很多基础数据结构和算法的设计思想。</strong></p>\n<p>比如，我们常用的Key-Value数据库Redis中，里面的有序集合是用什么数据结构来实现的呢？为什么要用跳表来实现呢？为什么不用二叉树呢？</p>\n<p>如果你能弄明白这些底层原理，你就能更好地使用它们。即便出现问题，也很容易就能定位。因此，<strong>掌握数据结构和算法，不管对于阅读框架源码，还是理解其背后的设计思想，都是非常有用的。</strong></p>\n<p>在平时的工作中，数据结构和算法的应用到处可见。我来举一个你非常熟悉的例子：如何实时地统计业务接口的99%响应时间？</p>\n<p>你可能最先想到，每次查询时，从小到大排序所有的响应时间，如果总共有1200个数据，那第1188个数据就是99%的响应时间。很显然，每次用这个方法查询的话都要排序，效率是非常低的。但是，如果你知道“堆”这个数据结构，用两个堆可以非常高效地解决这个问题。</p>\n<h2>基础架构研发工程师，写出达到开源水平的框架才是你的目标！</h2>\n<p>现在互联网上的技术文章、架构分享、开源项目满天飞，照猫画虎做一套基础框架并不难。我就拿RPC框架举例。</p>\n<p>不同的公司、不同的人做出的RPC框架，架构设计思路都差不多，最后实现的功能也都差不多。但是有的人做出来的框架，Bug很多、性能一般、扩展性也不好，只能在自己公司仅有的几个项目里面用一下。而有的人做的框架可以开源到GitHub上给很多人用，甚至被Apache收录。为什么会有这么大的差距呢？</p>\n<p>我觉得，高手之间的竞争其实就在细节。这些细节包括：你用的算法是不是够优化，数据存取的效率是不是够高，内存是不是够节省等等。这些累积起来，决定了一个框架是不是优秀。所以，如果你还不懂数据结构和算法，没听说过大O复杂度分析，不知道怎么分析代码的时间复杂度和空间复杂度，那肯定说不过去了，赶紧来补一补吧！</p>\n<h2>对编程还有追求？不想被行业淘汰？那就不要只会写凑合能用的代码！</h2>\n<p>何为编程能力强？是代码的可读性好、健壮？还是扩展性好？我觉得没法列，也列不全。但是，在我看来，<strong>性能好坏起码是其中一个非常重要的评判标准</strong>。但是，如果你连代码的时间复杂度、空间复杂度都不知道怎么分析，怎么写出高性能的代码呢？</p>\n<p>你可能会说，我在小公司工作，用户量很少，需要处理的数据量也很少，开发中不需要考虑那么多性能的问题，完成功能就可以，用什么数据结构和算法，差别根本不大。但是你真的想“十年如一日”地做一样的工作吗？</p>\n<p>经常有人说，程序员35岁之后很容易陷入瓶颈，被行业淘汰，我觉得原因其实就在此。有的人写代码的时候，从来都不考虑非功能性的需求，只是完成功能，凑合能用就好；做事情的时候，也从来没有长远规划，只把眼前事情做好就满足了。</p>\n<p>我曾经面试过很多大龄候选人，简历能写十几页，经历的项目有几十个，但是细看下来，每个项目都是重复地堆砌业务逻辑而已，完全没有难度递进，看不出有能力提升。久而久之，十年的积累可能跟一年的积累没有任何区别。这样的人，怎么不会被行业淘汰呢？</p>\n<p>如果你在一家成熟的公司，或者BAT这样的大公司，面对的是千万级甚至亿级的用户，开发的是TB、PB级别数据的处理系统。性能几乎是开发过程中时刻都要考虑的问题。一个简单的ArrayList、Linked  List的选择问题，就可能会产生成千上万倍的性能差别。这个时候，数据结构和算法的意义就完全凸显出来了。</p>\n<p>其实，我觉得，数据结构和算法这个东西，如果你不去学，可能真的这辈子都用不到，也感受不到它的好。但是一旦掌握，你就会常常被它的强大威力所折服。之前你可能需要费很大劲儿来优化的代码，需要花很多心思来设计的架构，用了数据结构和算法之后，很容易就可以解决了。</p>\n<h2>内容小结</h2>\n<p>我们学习数据结构和算法，并不是为了死记硬背几个知识点。我们的目的是建立时间复杂度、空间复杂度意识，写出高质量的代码，能够设计基础架构，提升编程技能，训练逻辑思维，积攒人生经验，以此获得工作回报，实现你的价值，完善你的人生。</p>\n<p>所以，不管你是业务开发工程师，还是基础架构工程师；不管你是初入职场的初级工程师，还是工作多年的资深架构师，又或者是想转人工智能、区块链这些热门领域的程序员，数据结构与算法作为计算机的基础知识、核心知识，都是必须要掌握的。</p>\n<p><strong>掌握了数据结构与算法，你看待问题的深度，解决问题的角度就会完全不一样</strong>。因为这样的你，就像是站在巨人的肩膀上，拿着生存利器行走世界。数据结构与算法，会为你的编程之路，甚至人生之路打开一扇通往新世界的大门。</p>\n<h2>课后思考</h2>\n<p>你为什么要学习数据结构和算法呢？在过去的软件开发中，数据结构和算法在哪些地方帮到了你？</p>\n<p>欢迎留言和我分享，我会第一时间给你反馈。</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"01 | 为什么要学习数据结构和算法？",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/ef/6e/efda7c5aaa517bffe5f917ced0f3ce6e.mp3",
		"column_id":126,
		"id":39972,
		"site_source_url":"https://time.geekbang.org/column/article/39972"
	},
	{
		"article_content":"<p>你好，我是王争，毕业于西安交通大学计算机专业。现在回想起来，本科毕业的时候，我的编程水平其实是很差的。直到读研究生的时候，一个师兄给了我一本《算法导论》，说你可以看看，对你的编程会很有帮助。</p>\n<p>没想到，从此我对算法的“迷恋”便一发不可收拾。之后，我如饥似渴地把图书馆里几乎所有数据结构和算法书籍都读了一遍。</p>\n<p>我常常边读边练。没多久，我就发现，写代码的时候，我会不由自主考虑很多性能方面的问题。我写出时间复杂度高、空间复杂度高的垃圾代码越来越少了，算法能力提升了很多，编程能力也有了质的飞跃。得益于此，研究生毕业后，我直接进入Google，从事Google翻译相关的开发工作。</p>\n<p>这是我自己学习数据结构与算法的经历，现在，你可以想想你的情况。</p>\n<ul>\n<li>\n<p>是不是从学校开始，你就觉得数据结构难学，然后一直没认真学？</p>\n</li>\n<li>\n<p>工作中，一遇到数据结构这个坑，你又发自本能地迅速避让，因为你觉得自己不懂，所以也不想深究，反正看起来无关大局？</p>\n</li>\n<li>\n<p>当你想换工作面试，或者研究某个开源项目源码，亦或者和团队讨论某个非框架层面的高可用难题的时候，你又发现，自己的基础跟不上别人的节奏？</p>\n</li>\n</ul>\n<p>如果你是这种情况，其实你并不孤独，这不是你一个人遇到的问题。工作十年间，我见过许多程序员。他们有着各种各样的背景，有很多既有潜力又非常努力，但始终无法在自己现有水平上更进一步。</p><!-- [[[read_end]]] -->\n<p>在技术圈里，我们经常喜欢谈论高大上的架构，比如高可用、微服务、服务治理等等。鲜有人关注代码层面的编程能力，而愿意沉下心来，花几个月时间啃一啃计算机基础知识、认认真真夯实基础的人，简直就是凤毛麟角。</p>\n<p>我认识一位原来腾讯T4的技术大牛。在区块链大潮之前，他在腾讯工作了10多年，长期负责手机QQ后台整体建设。他经历了手机QQ从诞生到亿级用户在线的整个过程。后来他去了微众银行，有一天老板让他去做区块链。<strong>他用了不到半年时间，就把区块链的整个技术脉络摸清楚了。</strong> 现在，他是微众银行的区块链负责人，微众科技创新产品部的老总。你说厉害不？你可以花半年时间就能精通一个新的领域吗？为什么他就可以做到？</p>\n<p>我觉得这其中最重要的就是基础足够扎实。他曾经跟我说，像区块链、人工智能这些看似很新的技术，其实一点儿都不“新”。最初学编程的时候，他就把那些基础的知识都学透了。当面临行业变动、新技术更迭的时候，他不断发现，那些所谓的新技术，核心和本质的东西其实就是当初学的那些知识。掌握了这个“规律”之后，他学任何东西都很快，任何新技术都能快速迎头赶上。这就是他快速学习并且获得成功的秘诀。</p>\n<p>所以说，<strong>基础知识就像是一座大楼的地基，它决定了我们的技术高度。而要想快速做出点事情，前提条件一定是基础能力过硬，“内功”要到位</strong>。</p>\n<p>那技术人究竟都需要修炼哪些“内功”呢？我觉得，无外乎就是大学里的那些基础课程，操作系统、计算机网络、编译原理等等，当然还有数据结构和算法。</p>\n<p>可是，我们都知道，像《算法导论》这些经典书籍，虽然很全面，但是过于理论，学起来非常枯燥；而市面很多课程大多缺失真实的开发场景，费劲学完感觉好像还是用不上，过不了几天就忘了。</p>\n<p>所以，我尝试做<span class=\"orange\">一个让你能真正受用的数据结构与算法课程，希望给你指明一个简洁、高效的学习路径，教你一个学习基础知识的通用方法</span> 。那么，关于专栏内容，我是怎样设计的呢？</p>\n<ol>\n<li>\n<p>我根据自己研读数十本算法书籍和多年项目开发的经验，在众多的数据结构和算法中，精选了最实用的内容进行讲解。</p>\n</li>\n<li>\n<p>我不只会教你怎么用，还会告诉你，我们为什么需要这种数据结构和算法，一点点帮你捋清它们背后的设计思想，培养你举一反三的能力。</p>\n</li>\n<li>\n<p>对于每种数据结构和算法，我都会结合真实的软件开发案例来讲解，让你知道，数据结构和算法，究竟应该如何应用到实际的编码中。</p>\n</li>\n</ol>\n<p>为了<strong>由浅入深</strong>地带你学习，我把专栏分成四个<strong>递进</strong>的模块。</p>\n<ol>\n<li><strong>入门篇</strong></li>\n</ol>\n<p>时间、空间复杂度分析是数据结构和算法中非常重要的知识点，贯穿整个专栏的学习过程。但同时也是比较难掌握的，所以我用了2节课来讲这部分内容，而且还举了大量的实例，让你一边学一边练，真正能掌握复杂度分析，为后面的学习铺路。</p>\n<p>我希望通过这一模块，你能掌握时间、空间复杂度的概念，大O表示法的由来，各种复杂度分析技巧，以及最好、最坏、平均、均摊复杂度分析方法。之后，面对任何代码的复杂度分析，你都能游刃有余、毫不畏惧！</p>\n<ol start=\"2\">\n<li><strong>基础篇</strong></li>\n</ol>\n<p>这部分是专栏中篇幅最大的内容，也是我们学习的重点，共有26节内容，涵盖了最基础、最常用的数据结构和算法。针对每种数据结构和算法，我都会结合具体的软件开发实例，由浅入深进行讲解，并适时总结一些实用“宝典”，保证你印象深刻、学有所用。</p>\n<p>比如递归这一节，我会讲到，为什么递归代码比较难写？如何避免堆栈溢出？如何避免递归冗余计算？如何将递归代码转化为非递归代码？</p>\n<ol start=\"3\">\n<li><strong>高级篇</strong></li>\n</ol>\n<p>这部分我会讲一些不是那么常用的数据结构和算法。虽然不常用，但是这些内容你也需要知道。设置这一部分的目的，是为了让你开拓视野，强化训练算法思维、逻辑思维。如果说学完基础部分可以考80分，那掌握这一部分就能让你成为尖子生！</p>\n<ol start=\"4\">\n<li><strong>实战篇</strong></li>\n</ol>\n<p>我们整个专栏都是围绕数据结构和算法在具体软件实践中的应用来讲的，所以最后我会通过实战部分串讲一下前面讲到的数据结构和算法。我会拿一些开源项目、框架或者系统设计问题，剖析它们背后的数据结构和算法，让你有一个更加直观的感受。</p>\n<p>人生路上，我们会遇到很多的坎。跨过去，你就可以成长，跨不过去就是困难和停滞。而在后面很长的一段时间里，你都需要为这个困难买单。对于我们技术人来说，更是这样。<strong>既然数据结构和算法这个坎，我们总归是要跨过去，为什么不是现在呢？</strong></p>\n<p>我很感激师兄当年给我的那本《算法导论》，这是我人生中为数不多的转折点之一。没有那本书，也可能就没有今天的我。我希望这个专栏也能成为你的一个人生转折点。</p>\n<p>我希望，通过这个专栏，不仅能帮你跨过数据结构与算法这个坎，还能帮你掌握一种学习知识和技能的方法，帮你度过职场甚至人生的重要时刻！一起加油吧！</p>\n<p><img src=\"https://static001.geekbang.org/resource/image/8e/d3/8e603e3d795fc0ab2698f6f5eabf14d3.jpg\" alt=\"\" /></p>\n",
		"article_title":"开篇词 | 从今天起，跨过“数据结构与算法”这道坎",
		"audio_download_url":"https://res001.geekbang.org/resource/audio/b0/8c/b0baba110ab8f899da8ee683c407c18c.mp3",
		"column_id":126,
		"id":39922,
		"site_source_url":"https://time.geekbang.org/column/article/39922"
	}
];

        $(function () {
            var html = "";
            for (var i = 0; i < articleList.length; i++) {
                var article = articleList[i];
                var id = article["id"];
                var article_title = article["article_title"];
                var article_content = article["article_content"];
                var audio_download_url = article["audio_download_url"];
                html += "<li class=\"ol_api\">" +
                        "                <div>" +
                        "                    <span class=\"api_title\" article_id=\"" + id + "\">" + article_title + "</span>" +
                        "                    <div class=\"clear_both\"></div>" +
                        "                </div>" +
                        "            </li>";
            }
            $("#ol_apis").html(html);

            $(".api_title").click(function () {
                var tar = $(this);
                $(".ol_api").removeClass("selected");
                var article_id = tar.attr("article_id");
                for (var i = 0; i < articleList.length; i++) {
                    var article = articleList[i];
                    var id = article["id"];
                    if (id == article_id) {
                        var article_title = article["article_title"];
                        var article_content = article["article_content"];
                        var audio_download_url = article["audio_download_url"];
                        var site_source_url = article["site_source_url"];
                        $("#article_title").attr("href",site_source_url).html(article_title);
                        var video = '<video id="videomp3" src="' + audio_download_url + '" autoplay="autoplay" preload="none" controls="controls"></video>';
                        $("#audio_download_url").html(video);
                        $("#article_content").html(article_content);
                    }
                }

            });
        });
    </script>
</head>
<body>
<div class="con_left">
    <div style="padding:10px;">
        <div style="color: #CC0000; font-size:18px; font-weight: bold; text-align: center">数据结构与算法之美</div>
        <ol class="ol_apis" id="ol_apis">
        </ol>
    </div>
</div>
<div class="con_right">
    <div id="con_right">
        <div><a href="" target="_blank" id="article_title"></a></div>
        <div id="audio_download_url"></div>
        <div id="article_content"></div>
    </div>
</div>
</body>
</html>